NanookNanook
  • Docs
  • API
  • Blog
  • GitHub

›Recent Posts

Recent Posts

  • 2026-09 Tests Got Cheap
  • 2026-08 E-Invoices: 40 Dialects
  • 2026-08 One Login, Eight Cases
  • 2026-08 Testing a SaaS
  • 2026-03 AI-Assisted Tables
  • 2026-03 Manual vs. Automated
  • 2026-03 Equivalence Class Testing
  • 2026-02 Nanook Is Back
  • 2019-06 Introducing Nanook

One Login Form, Eight Test Cases: A Worked Example

August 22, 2026

Torsten Link

The post announcing the /createEquivalenceClassTable skill explains how it works: the six workflow steps, the CASCADE markers, what lands in the Excel file. What it does not contain is one complete, small example with real output. This is that example. Everything below — the table, every generated value, the error we hit — came out of an actual run, not out of an illustration. If you want the mechanics of the skill itself, read that post first; here we only use it.

The subject is a login form, on purpose. Everybody understands one, nothing needs explaining, and most teams already have tests for theirs — which makes it the best object for a comparison at the end.

Ask for a Table

One line is the starting gun:

/createEquivalenceClassTable Login Form

That is all the prompting there is. The table shown next was built to the skill’s rules and run through Nanook; the prompt is the entry point, and the rest of this post is about what comes out of it.

What Comes Back

An equivalence class table — Nanook calls it a decision table — with eight columns, one per test case. Here it is with the bookkeeping rows (Execute, Multiplicity) removed, and the class counts and the coverage sum added — the skill’s Excel formulas produce the same rows. The generator column says where run-time values come from — gen: calls a registered generator, a bare value is used as-is; more on it in “When Faker Is Not Enough” below. Eight cases side by side are meant to be read across, not down: one column is one test case (scroll the table sideways if your screen is narrow).

generatorrule
OK_login
E_emailEmpty
E_emailNoAt
E_emailTooLong
E_passwordEmpty
E_passwordTooShort
E_unknownUser
E_lockedUser
Secondary data — what must already exist
account3 classes11111111
exists, activeactivethe normal worldxxxxxx
does not existabsentnobody registered thatx
lockedlockedtoo many failed attemptsx
Primary data — the login form
email4 classes11111144
validgen:1:faker:internet.emailxxxaa
emptymin(1)xee
no @torsten.linkformatxee
too longgen::len:255max(254) RFC 5321xee
password3 classes13331133
validgen:1:faker:internet.passwordxaaaaa
emptymin(1)eeexee
too shortabmin(8)eeexee
Summary — combinations covered
product of the counts above3 × 4 × 3 = 36 in total1333111212
1 + 3 + 3 + 3 + 1 + 1 + 12 + 12 = 36 of 36 — 100 % coverage
Expected reaction
signed insession cookie is setx
rejectedwith a messagexxxxxxx
Expected effect
failed-attempt counter risesreal account, wrong passwordx
failed-attempt counter unchangedsuccess or unknown userxx

Four things to look at, in this order.

  1. Two sections, not one. Secondary data describes the world the test lands in; Primary data is what the form receives. Whether the account is locked is not something you type into a login form — it has to be true before the test starts. Keeping the two apart is what lets the table say “valid credentials, locked account” as one case instead of a paragraph of setup code.
  2. Equivalence classes, not values. email has four: valid, empty, no @, too long. Each stands for infinitely many concrete inputs. password has three, account has three. The table never lists a specific email address as a row; it lists the reasons an address can be accepted or rejected.
  3. The arithmetic. The small number next to each field counts the classes a case claims there; the Summary row multiplies them per column. E_unknownUser claims 1 × 4 × 3 = 12 combinations — the account is the one thing wrong, so every email class and every password class is admissible. The eight products sum to 36, which is exactly the 3 × 4 × 3 the three fields span: 100 % of the combination space, claimed by eight columns.
  4. The markers. x selects exactly this class for the case. a marks the preferred class where several are admissible — that is the one Nanook generates. e marks the other admissible classes: they count towards coverage but are not generated unless no a or x is present. Together a and e are the CASCADE pattern from the earlier post, and the reason eight columns cover every class exactly once.

The last two rows are the ones people miss. Note what is not there: no row says “the counter must not rise”. A blank cell asserts nothing — a table states its expectations positively, one row per statement, each selected with an x. So the counter gets two rows — rises and unchanged — and the cases take sides: a real account with a wrong password demands the first, a successful login and an unknown user demand the second. Five columns say nothing about the counter; three take a position, and the two positions contradict each other. That contradiction is the assertion: a probe that always reports a rising counter fails two cases, one that never reports it fails the third.

Read one column and the method explains itself

Take E_passwordTooShort and read it top to bottom: account active, email valid, password too short. Exactly one thing is wrong; everything else is right.

That is the whole discipline of error-case design, and in a table you can see it at a glance instead of reasoning about it. The counter-example is one everybody has written at some point: a test that submits an empty email and a two-character password. It passes when the form rejects either one — and therefore proves nothing about which. If the password check is later removed by accident, that test stays green. The column above cannot: its email is valid, so the only thing that can produce the rejection is the password rule.

The Data It Generates

One column of the decision table, E_passwordTooShort with its three x marks on account active, email valid and password too short, mapped by an arrow to the generated JSON test case with account active, a generated email, and the password ab

Running the table through Nanook produces one object per column. Reduced to the fields that matter, this is a single run, values unedited:

case                 account   email                              password           expected     effect
OK_login             active    Jennifer_Tremblay@hotmail.com      VuZGJBushpsbY_b    signed in    counter unchanged
E_emailEmpty         active    —                                  iu2MngWY7bxFuXF    rejected
E_emailNoAt          active    torsten.link                       0qeDxgDRG0kwRAT    rejected
E_emailTooLong       active    aaaa…aaaa (255)                    3laNNg1Jt_jjWCk    rejected
E_passwordEmpty      active    Kelli_Ankunding54@gmail.com        —                  rejected
E_passwordTooShort   active    Kurtis_McGlynn27@yahoo.com         ab                 rejected     counter rises
E_unknownUser        absent    Cody.Kuhic-Kuphal@yahoo.com        gRL7wb1Xz1m2maI    rejected     counter unchanged
E_lockedUser         locked    Johan_DuBuque@hotmail.com          8FnRb944emJsD4c    rejected

(The 255-character address is abbreviated for display; the generated value is 255 letters a.)

A few things are only visible in the output, not in the table:

  • E_emailEmpty has no email key at all. The dash is not an empty string — the field is absent from the object. An empty class means the field is not sent, which is a different test from sending "", and a form that handles one may well mishandle the other.
  • E_emailTooLong is exactly 255 characters: one over the 254-octet limit of RFC 5321. A boundary case is only a boundary case if the number is exact; “some long string” tests nothing.
  • The passwords differ in every row. They are generated per run, not a fixture. A test that hard-codes Test1234! passes forever after someone adds a password blocklist that happens not to contain it; a generated value gets a fresh chance to collide with every rule on every run.
  • account varies only in the two rows where it is the point. Everywhere else it is active, the normal world — which is what makes the other six cases statements about the form and not about the account.

When Faker Is Not Enough

The first version of the table did not use gen::len:255. It used what looked obvious: gen:1:faker:string.alpha:255. That does not work, and it is worth saying exactly why.

A Faker path in a generator directive takes no arguments. The directive is split on . and called as a path — string.alpha:255 is split into string and alpha:255; the second is not a Faker function, and the call throws.

What happened when we ran it: Nanook logged the error and the case was not emitted. Seven test cases came out instead of eight. The run did not stop; the error sat in the log, and the other seven cases were perfectly fine. If nothing in your pipeline checks the count of generated cases against the count of table columns, a case can disappear this quietly. Check the number.

The fix is a generator of our own, ten lines:

// `gen::len:255` -> a string of exactly 255 'a'. Faker paths take no
// arguments, so an exact length needs a generator of its own.
class GeneratorLen extends DataGeneratorBase {
  async doGenerate(request: DataGeneratorGenerateRequest) {
    const cfg = request.generatorDirective?.config
    const count = Number.parseInt(String(cfg ?? '').replace(/^len:/, ''), 10)
    return Number.isFinite(count) ? 'a'.repeat(count) : undefined
  }
}

And one line to make it available under the name the table uses:

registry.registerGenerator('len', new GeneratorLen({ logger, name: 'len' }))

After that, eight cases. The custom generator tutorial covers the interface; the directives guide has the full gen: syntax, including the instance number.

What the Table Is Worth After Day One

Eight cases for a login form is not a large number, and it is not the claim. Most teams already have a handful of login tests. The value is in two properties the eight cases have that the hand-written ones usually lack.

The specification is readable by someone who does not write TypeScript. The table above says what the login form must do, in the vocabulary of the form. Add a class to email — say “unicode domain” — and the case count follows on its own; the person adding it does not need to know how the test runner is built. Compare that with a const CASES = [...] inside a spec file, which belongs to whoever writes the spec file.

The same table drives execution. The eight objects above are not documentation; they are the input to whatever runs the test. How that scales past one form — to a whole application, with the table as the only source — is the subject of the field report on testing a SaaS with Nanook; this post stops at the eight.

Try It on Your Own Form

Two steps. Install Nanook and the skill following the Quickstart with Claude Code, then run the slash command against your own login page. The tutorial walks through the markers if the table needs editing, and the equivalence class guide explains why the classes are chosen the way they are. The source is on GitHub.

Point it at your own login page. Run /createEquivalenceClassTable Login Form, and compare the result with the login test cases you have today. The interesting number is not how many cases it produced — it is how many of them you already had.

Recent Posts
Nanook
Docs Tutorials Guide
More About Imprint Privacy Policy GitHub Manage Cookies
© 2018-2026 nanook.xhub.io — An Open Source Project by BeeBack UG.
Cookie preferences

We use cookies to analyze site usage and improve your experience. You can choose which cookies to allow below. See our Privacy Policy for details.

EssentialAlways active
Analytics