DEV Community

Weiwen Weng
Weiwen Weng

Posted on Originally published at gitoza.com

Test Cases as Markdown in Git

A spreadsheet works until two people edit it. A hosted test tool works until the team is six people and the UI is already in the way. If the work already lives in a Git repository, the manual test cases can live there too.

This is the layout I use. One Markdown file per case. YAML front matter for the fields you filter and link. A separate folder each time you execute. The files stay readable in any editor, and an agent can write a case because the schema is the file.

Who this is for

Solo developers and small teams who already have a repo. You want a procedure a person can run, a history of what changed, and a link from the case back to the ticket.

Automated tests stay in code. This format is the spec a human follows, and the record of a manual run.

The case and the run are different files

The case is the procedure. It should change when the product changes.

The run is one execution. It changes every time someone marks a case pass or fail.

Put both in the same file and every test session rewrites the spec. The diff used to mean "the login steps changed." After a few runs it means "someone tested this on Tuesday." Review becomes noise, and a merge between two testers gets worse.

Split them. In Gitoza the tree looks like this:

File What it holds When it changes
.gitoza/test/cases/<project>/…/<case_id>.md Title, fields, steps, expected result When the procedure changes
.gitoza/test/runs/<run_id>/head.md Run title, tickets, release When the run is created
.gitoza/test/runs/<run_id>/cases.md Which cases are in the run When membership changes
.gitoza/test/runs/<run_id>/results.md Pass, fail, skip During execution

The case id is the filename. AUTH-001.md is case AUTH-001. One case, one file. Creating a case in the app assigns an id like TC-…. A file you or an agent write can use any id of letters, numbers, underscores, and hyphens, as long as the filename matches.

Run files are YAML front matter only. A YAML mapping between --- fences, with an empty body. The case file is the one with a real Markdown procedure underneath.

YAML front matter is the row. The body is the procedure.

Fields you want to filter, assign, or link go between the --- lines. The steps a person follows go below them.

A file is valid with a title:

---
title: Verify login error message
---
Enter fullscreen mode Exit fullscreen mode

That is enough for the app to index it. A case you will actually run picks up a few more fields. priority, status, and tags are for search. requirement_ids is the list of tickets this case covers. params holds data that changes by environment, so the steps can stay the same on staging and production.

---
title: Verify login error message
status: active
priority: high
tags:
  - auth
  - smoke
requirement_ids:
  - GITO-BUG001
params:
  environment: staging
---

## Steps

1. Open the login page.
2. Enter invalid credentials.
3. Submit the form.

## Expected

A clear error message is shown. A valid login redirects to the dashboard (see [[GITO-BUG001]]).
Enter fullscreen mode Exit fullscreen mode

status is the life of the case: active, draft, or deprecated. Review is a different field, approve_status (draft, approved, rejected). Using one field for both makes a filter lie about what is ready to run.

One action per numbered step. In a manual run, that step is the thing on screen.

Traceability is text in the repo

requirement_ids on the case lists the tickets it covers. In the body, [[GITO-BUG001]] links to that ticket. Open the ticket and this case is listed under Tested by.

The link is a string in the file. Copy the repository and the link is still there. Close the desktop app and the link is still there.

A run points the other way. head.md lists the tickets that run covers, and can name the release it was executed against. On the ticket, those runs show up as Covered by runs. Results stay in the run folder. They are not copied back into the case.

---
run_id: R-A1B2C3
title: Login smoke, staging
requirement_ids:
  - GITO-BUG001
---
Enter fullscreen mode Exit fullscreen mode

results.md is sparse. Only cases that have been executed appear. Counts are derived when you open the run, so a stale passed: 4 is not something two people have to merge.

---
run_id: R-A1B2C3
results:
  .gitoza/test/cases/MyProject/01_auth/AUTH-001.md:
    result: passed
    executed_at: "2026-04-02T09:15:00Z"
---
Enter fullscreen mode Exit fullscreen mode

The app writes more bookkeeping around these keys (dates, review, comments). The split above is the part that keeps history readable.

Review the diff on its own branch

A case change is an ordinary Git diff. A step was added. A tag was removed. A ticket id was linked. That diff is the review.

These files live on a branch named gitoza, separate from application code. A pull request for a feature stays about the feature. Test and ticket edits are reviewed on their own branch: you look at a changelist, then confirm, and fetch, rebase, and push happen in the background. If two people edited the same item, sync pauses on Keep mine or Keep shared version.

Git history of the case file is the audit log. You can see who changed the expected result, and you can check out an older copy of the procedure.

What makes the repository painful

Three habits break this.

Screenshots committed as binaries. A PNG in the case history makes every clone heavier, and the diff stops being text. Refer to the image from the Markdown and keep the bytes in asset storage. The commit stays a documentation change.

Two people rewriting the same case. A Markdown procedure merges badly when both sides rewrite the steps. Smaller files limit the damage: one case per file, membership in cases.md, outcomes in results.md. When an overlap does happen, stop and pick a version. A silent merge in the middle of a YAML block is how a case disappears from search.

Pasting automation output into the case. A JUnit file is a result from a build, in the same family as results.md. Upload the XML to the place your CI results already go, and read it there. On the case, automated: true is enough to say code already covers the procedure. The stack trace from last night's job belongs with the build, beside the case.

Let an agent write the file

Give the agent the path and the headings. It does not need a plugin.

  • Create .gitoza/test/cases/<project>/<folder>/<case_id>.md.
  • The filename is the case id. Letters, numbers, underscores, and hyphens only.
  • Start the YAML front matter with title:. Add priority, status, tags, and requirement_ids when you know them.
  • Use ## Steps and ## Expected. One action per numbered step.
  • Put the ticket id in requirement_ids. If the sentence mentions the ticket, write [[TICKET-ID]] too.
  • Leave pass, fail, and skip out of the case file. Those belong in a run.

Refresh the index after the file is on disk. Search and filters read the YAML front matter. The body renders as a page. After that, edit in the UI. The raw file is there when you want a diff or an agent. Day to day you can ignore it.

A title alone is a valid case. Fill the other fields when the case is real.


I use this layout in Gitoza, a local desktop app for tickets, a wiki, and tests stored as Markdown in Git. Sync commits to the gitoza branch after you review the changelist. There is no account.

Source (Apache-2.0): github.com/gitoza-io/gitoza

Top comments (0)