Skip to content

The fields of a case

Beside its title and its body, a case carries four things about how it matters and one about what it is tagged. All of them travel through POST /v1/test-cases and PATCH /v1/test-cases/{id}, and every one of them appears in the audit log as “was → became”.

Two free-text fields, description and precondition, up to 16 KiB each. They are shown as written — line breaks kept, no markup rendered yet — and on the dashboard they sit under their own headings on the case page and as two text areas on the form. Sending null in a PATCH clears one; leaving it out keeps it.

These two are the text of the case, so changing either raises the case’s version — a result that points at version 3 tested what version 3 said. Priority, tags, position and the suite are metadata: changing them does not move the version.

The steps of a manual case are text too, and each action is also a row of the project’s Steps Database: the form suggests the steps the project already has as you type, and renaming a step there rewrites it in every case that carries it.

A case that should run on several sets of data carries one table — the way Testomat has its dynamic parameters: params are the column names, every row of rows is one iteration, with exactly one value per column.

{
"title": "login as ${Username} @smoke",
"steps": [{ "action": "Sign in as ${Username}", "expected": "The ${Role} home opens" }],
"examples": { "params": ["Username", "Role"], "rows": [["admin", "admin"], ["user", "user"]] }
}

${Name} — or {{Name}} — in the steps, description, preconditions and prompt stands for that column’s value; in the title only ${Name}. Names are case-sensitive (Username is not username) and unique; 1 to 16 of them, up to 64 characters, without $, { or }; 1 to 100 rows; the whole table under 16 KiB. A row of the wrong length, a duplicate name, a table without a header row — each is refused with its rule:

400 · validation failed — examples.params: parameter names must be unique

null in a PATCH clears the table; leaving it out keeps it. The table is content, like the two texts: changing it raises the case’s version. Every row is run and reported on its own — a result carries the row it ran as params, and the case’s history shows them, so a red line says which set of data failed. In the Markdown document the table is the <!-- example --> block at the end of the case.

On the dashboard: a Parameters section in the form on every type of case — the table as fields, + column / + row, ✕ on each; and on the case page the table with a row per iteration and, when the title has a ${Name}, the title each row runs under.

Five levels, in rank order:

low · normal · important · high · critical

normal is the default. The order is not decoration — priorityMin=high on the list means “high and critical” because the levels are ranked, and the dashboard shows each as a distinct glyph beside the title so the scale reads without colour.

Anything outside the five is refused with the list:

400 · validation failed — priority: must be one of low, normal, important, high, critical

Who is to run the case: assigneeId, the userId of a member of the project, or absent while nobody has been asked. It is set after the fact, never on create:

PATCH /v1/test-cases/{id}
{ "assigneeId": "6f1c2b3a-0000-4000-8000-0000000000a2" }

null takes the case back; a PATCH that does not name the field keeps whoever holds it. Anyone who is not in the project is refused with the reason — a real user in another project the same way as an id nobody holds, because the surface says nothing about who exists elsewhere:

400 · user '…' is not a member of this project

Assigning is metadata, like a priority: the case’s version does not move. Leaving the project, or being removed from it, takes the person off every case they held there — «the assignee is a member» stays true, rather than true at the moment of writing.

On the dashboard the case page shows the assignee as a circle with a name and a select behind it, «Unassigned» on top; a row of the tree carries the circle; the filter bar’s Assignee facet is Me · Unassigned · the members; and the selection bar’s Assign… hands out (or takes back) several at once — Many cases at once. A launch with Prefer case assignee copies the assignees into the run — Manual runs.

What people said about the case, beside the case — «step 3 is stale since the checkout redesign» belongs where the next person to run it will read it. Plain text with its line breaks, no Markdown; one author, one moment, no edit — a changed mind is a second comment.

GET /v1/test-cases/{id}/comments → { "comments": [{ "id", "authorId", "body", "createdAt" }] }
POST /v1/test-cases/{id}/comments { "body": "Step 3 is stale since the checkout redesign." }
DELETE /v1/comments/{id}

The list is the live comments, oldest first; the author is a userId, named on the dashboard from the members list. The body is trimmed, never empty and at most 8 192 bytes — the same cap the column enforces, so it is a 400 with the reason rather than a 500. Anyone in the project reads them, a reader too; a reader who tries to write is refused at the boundary, 403 read-only member.

Deleting is soft — the row stays for the audit entry that names it, the list stops showing it — and it is the author’s to do, or an owner’s on anyone’s. Somebody else’s comment answers 404 to everybody else, the same answer an id nobody has gets, on purpose. A case in the bin, or one in another project, has no comments to show and takes none: the case’s own 404.

Both writes land on the case’s audit feed as test_case/comment, the text in the diff — written or taken back. They are not undone from the feed: a comment is deleted on the case.

On the dashboard the case page ends with Comments · N: a row is the person, the time and the text; Delete on your own (an owner: on anyone’s); the box and Comment below the list for a member, none for a reader. A comment posted appears in place, a deleted one leaves in place — no reload either way.

position is a case’s place among the cases of its suite (or among the unfiled ones), dense and zero-based. The list comes back in that order, suite by suite; sending a new position in a PATCH moves the case and renumbers its neighbours. Suites have the same field for the same reason.

A tag is written in the title, as @name, and that is where it is stored:

Checkout keeps the total right @smoke @payments

The rules are the ones the wider tool ecosystem already uses: a tag starts the title or follows a space or a tab, its body is made of \w = - _ ( ) . : & and ends in a word character or ), it is shorter than 120 characters, and its case is yours — Smoke and smoke are two tags. An e-mail address or a @ in the middle of a word is not a tag.

The API reads them out for you: every case answers with tags — the @names of its title, without the @, each once, in order — and the dashboard shows the clean title with the tags as chips. You can write them back either way:

  • put them in the title — "title": "Checkout keeps the total right @smoke";
  • or send tags beside a clean title — "title": "Checkout keeps the total right", "tags": ["smoke"], and the stored title gets the @smoke tail written for you.

When both are sent, tags wins over the title’s own tail; "tags": [] strips every tag. Two refusals guard the field:

RefusalWhen
400 · validation failed — title: a title cannot consist of tags onlynothing is left once the tags are removed
400 · validation failed — tags.1: invalid tag 'smoke test' — a tag is 1 to 119 characters from \w = - _ ( ) . : &a tag breaks the rule — the refusal names it

GET /v1/settings/tags lists the project’s tags with how many cases carry each — Smoke beside smoke, each with its own count. The same list is Settings → Tags on the dashboard, and two operations act on it:

  • Rename — POST /v1/settings/tags/rename with { "from": "smok", "to": "smoke" } rewrites the title of every case that carries from, one audit entry per case. If to already exists the two simply merge; to is checked against the tag rule first.
  • Merge — POST /v1/settings/tags/merge with { "from": ["Smoke", "smoke-test"], "into": "smoke" } replaces every tag in from with into, in every title, in one transaction; into need not exist yet, and from must not contain it.

Both answer how many cases they touched, and neither moves a case’s version — a tag is metadata.