ProductHow it worksPricingBlogDocsLoginFind Your First Bug
Requirements traceability matrix: threads from requirements to tests, one frayed where a requirement has no covering test
TestingRequirements Traceability MatrixTest Coverage

Requirements Traceability Matrix: 10 Rows, Filled In

Tom Piaggio
Tom PiaggioCo-Founder at Autonoma

A requirements traceability matrix (RTM) links each requirement to the artifacts that prove it was built and tested: a requirement ID, its description, the source it came from, the test case IDs that exercise it, whether those tests are automated, the result of their last run, and a coverage verdict. Below is a filled example for a real feature set, a script that generates the coverage half of it from a JUnit report, and the reason a hand-maintained version of this table is wrong by the time anyone reads it.

If an auditor, an enterprise prospect's security questionnaire, or a take-home interview task just asked for "a traceability matrix" and you need something real today, skip to the filled table below. This isn't for a QA lead deciding whether to invest in traceability as a practice, and it isn't for a team asking whether their AI-generated tests actually check anything, both are different questions with their own pages. It's for whoever has to produce the artifact by end of day: a QA engineer or SDET being evaluated on the vocabulary, onboarding a junior tester, or answering "where's your RTM" from someone who won't accept a screenshot.

That detail matters more than it sounds. Most published examples of a traceability matrix are screenshots, and a screenshot can't be copied into a spreadsheet, searched, or read by anything but a human eye, a strange failure mode for an artifact whose whole job is to be checked against. What follows is a real table, in the page, so you can select it.

What goes in an RTM

Seven columns carry the idea, each answering a question the others cannot.

The requirement ID is a stable identifier, not the requirement text itself, so a row survives a rewording without breaking every link that points at it. The description is the requirement in plain language, short enough to scan a hundred rows without losing the thread. The source is where the requirement came from: a PRD section, a Jira epic, a compliance control, or a contract clause, the detail an auditor checks first because it's the difference between "we decided this" and "we were told this."

Test case IDs are every test that exercises the requirement, and the column can hold more than one ID, or none. Automation status says whether a test runs by itself or needs a person; hiding manual coverage behind a green checkmark misrepresents a Friday deploy. Last run result is the outcome of the most recent execution, not the best one it ever produced. The coverage verdict is the row's conclusion: covered, covered but stale, covered but failing, or not covered at all.

Two directions run through those columns. Forward traceability starts at a requirement and asks which tests exist for it, the direction a coverage report runs. Backward traceability starts at a test or a defect and asks which requirement it belongs to, the direction you need when something breaks in production and nobody remembers why the check exists. The section after the filled matrix walks the backward direction on a real defect.

A filled requirements traceability matrix example

Ten rows from a subscription checkout and account flow for a B2B SaaS product, the kind of feature set most teams building on Stripe or a similar billing provider eventually own. Real requirement IDs, real sources, and verdicts that follow honestly from the columns to their left, including one requirement with no test at all and two rows where a test passed but the verdict still carries a caveat.

Req IDDescriptionSourceTest case IDsAutomation statusLast run resultCoverage verdict
REQ-010Checkout completes with a valid cardPRD, Checkout §3.2TC-101AutomatedPassCovered
REQ-011Expired card is rejected with the correct decline reasonPRD, Checkout §3.4TC-102AutomatedPassCovered
REQ-012Failed renewal charge retries 3 times before past-dueJira epic PAY-88TC-103AutomatedFailGap: regression
REQ-013Mid-cycle upgrade prorates to the exact days remainingPRD, Billing §4.1TC-104ManualNot runStale: checked 3 releases ago
REQ-014Cannot downgrade below the current assigned seat countJira epic ACC-42NoneNot coveredNot runGap: no test
REQ-015Cancellation requires an explicit confirmation stepPRD, Account §5.2TC-101AutomatedPassCovered, shared test
REQ-016Canceled data purges after the 90-day retention windowEnterprise MSA, retention clauseTC-107ManualPassCovered, checked once
REQ-017Enterprise-tier accounts authenticate through SSO onlyPRD, Enterprise §6.1TC-108, TC-109AutomatedPassCovered
REQ-018Billing admin can view invoices, not edit payment methodJira epic ACC-51TC-110AutomatedNot runStale: quarantined flaky
REQ-019Expired trial converts account to read-only, not deletedInternal control DS-4TC-111AutomatedPassCovered, narrow assertion

Two rows do extra work on purpose. REQ-010 and REQ-015 share test case TC-101, because one end-to-end run that signs up, completes checkout, and cancels touches both requirements in a single pass. REQ-019 shows "narrow assertion," not just "Covered," because its test confirms the account's state flips to read-only and stops there. Both are early tells for "Covered is not verified" further down.

REQ-014 is the row every vendor page skips: a real requirement, a real source, and nothing testing it. That gap is the entire reason this table exists. A matrix that only ever shows green rows isn't tracking coverage, it's decoration.

Using this as a traceability matrix template

Select the table above and paste it into Sheets or Excel. The seven columns paste as seven columns and the header row survives, which is the whole point of publishing it as a table instead of a screenshot. Then cut what you don't need: if nobody is auditing you, source and automation status can go, and the four columns left are the minimum that still answers "which requirements have no test." If somebody is auditing you, source is the column they will open first, so keep it and fill it with a real reference rather than a document name. For how to structure the test case IDs column itself, that's a separate template.

Forward and backward traceability, and what bidirectional adds

Forward traceability is the direction most explanations stop at: start with REQ-012 (a failed renewal charge should retry three times before the subscription is marked past due), find TC-103, see that its last run failed. Useful, but it only tells you a test exists and what it did last time.

Backward traceability starts from the other end, and it's the direction that actually finds something.

Say a bug report lands: BUG-233, a customer whose card was correctly declined but whose subscription never retried the charge. Walk it backward: the bug traces to the failing run of TC-103, which traces to REQ-012, which traces to Jira epic PAY-88, the billing retry logic. Check whether any other open defect traces through the same feature area, and BUG-241, a proration rounding error, also traces back through Billing, to REQ-013. Two open defects rooted in billing logic, versus zero in checkout.

That's bidirectional traceability in practice: forward to confirm what's covered, backward to find which feature keeps producing the defects, a question the matrix makes answerable instead of a guess based on who complained loudest.

A three-column diagram of forward and backward traceability across a Requirement, Test and Defect column. Grey left-to-right arrows run from REQ-012 to TC-103 to BUG-233 and from REQ-013 to TC-104 to BUG-241 inside a Billing group, while a thicker lime arrow path runs the other way from BUG-233 back through TC-103 to REQ-012 as the backward read. A separate Checkout group holding REQ-010 and REQ-011 has an empty dashed defect box reading zero open defects, so two defects in billing contrast against none in checkout
Forward only tells you a test exists. Backward is the read that turned two unrelated bug reports into one finding about where this product's defects actually cluster.

Notice what the backward walk didn't require: nobody had to remember PAY-88 was a troubled epic. The matrix carried that in its structure, surfacing a pattern, billing logic, not checkout, is where this product's defects cluster, that neither defect alone would have shown.

Generating the coverage column

Six of the seven columns above are things a person decides: the requirement ID, its description, its source, which tests were written for it. The last three, current test state, last run result, and coverage verdict, are things a person currently transcribes from a test report into a spreadsheet by hand, and transcription is where a matrix starts drifting from reality.

That half can be generated instead. The convention: every test exercising a requirement carries a marker naming that requirement's ID, and a small conftest hook writes each marked requirement into a report following the JUnit XML report format as a property on that test's result.

"""
The marker half of the requirement-traceability convention.

`rtm_coverage.py` reads requirement IDs out of a JUnit XML report, as
<property name="requirement" value="REQ-010"/> elements nested under each
<testcase>. This file is what puts them there.

Two small pieces:

1. `pytest_configure` registers the `requirement` marker, so tagging a test
   with @pytest.mark.requirement("REQ-010") emits no PytestUnknownMarkWarning
   (and does not fail a CI run that treats warnings as errors). pytest.ini
   registers the same marker, for anyone who runs the tests without this
   conftest on the path.

2. `pytest_collection_modifyitems` copies every marker argument onto the test
   item's `user_properties`. pytest's built-in JUnit XML writer serializes
   `user_properties` into the <properties> block of the matching <testcase>,
   so there is no custom XML writing to maintain here.

The second piece is a collection hook rather than an autouse fixture on
purpose. An autouse fixture never runs for a test skipped by a marker, which
would silently drop the requirement IDs of every quarantined test: exactly
the tests whose coverage you need reported as Stale rather than vanishing
into a Gap.
"""

import pytest

REQUIREMENT_MARKER = (
    "requirement(*ids): requirement IDs this test provides coverage for, "
    "emitted into the JUnit XML report as `requirement` properties."
)


def pytest_configure(config):
    config.addinivalue_line("markers", REQUIREMENT_MARKER)


def pytest_collection_modifyitems(session, config, items):
    for item in items:
        for marker in item.iter_markers(name="requirement"):
            if not marker.args:
                raise pytest.UsageError(
                    f"{item.nodeid}: @pytest.mark.requirement needs at least "
                    "one requirement ID"
                )
            for req_id in marker.args:
                item.user_properties.append(("requirement", req_id))

With that convention in place, pytest --junitxml=report.xml produces a report where every <testcase> element carries its requirement IDs as properties. A short script reads that report, groups results by requirement ID, and prints a verdict for each: Pass if every covering test is green, Fail if any covering test failed on its last run, Stale if every covering test was skipped, and Gap if a requirement has no covering test in the report at all.

"""
Generate the coverage half of a requirements traceability matrix from a
JUnit XML report.

Convention: each test function is tagged with a `req_marker` property that
carries one or more requirement IDs, comma-separated. In pytest this maps to
a custom marker registered as:

    @pytest.mark.requirement("REQ-014")
    def test_checkout_blocks_expired_card():
        ...

...and a conftest.py hook that writes the marker's argument into the
JUnit XML as a <property name="requirement" value="REQ-014"/> under each
<testcase>. Any JUnit-producing framework works the same way as long as the
requirement ID ends up as a <property> on the <testcase> element.

Run: python3 rtm_coverage.py report.xml REQ-001 REQ-002 REQ-003 ...
"""
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict


def parse_junit(path):
    """Return {requirement_id: [(test_id, result), ...]}."""
    tree = ET.parse(path)
    root = tree.getroot()
    coverage = defaultdict(list)

    for testcase in root.iter("testcase"):
        test_id = f'{testcase.get("classname")}::{testcase.get("name")}'

        if testcase.find("failure") is not None or testcase.find("error") is not None:
            result = "Fail"
        elif testcase.find("skipped") is not None:
            result = "Not run"
        else:
            result = "Pass"

        req_ids = []
        for prop in testcase.iter("property"):
            if prop.get("name") == "requirement":
                req_ids.append(prop.get("value"))

        for req_id in req_ids:
            coverage[req_id].append((test_id, result))

    return coverage


def verdict(results):
    if not results:
        return "Gap: no covering test"
    if any(r == "Fail" for _, r in results):
        return "Fail: covering test failing"
    if all(r == "Not run" for _, r in results):
        return "Stale: covering test not run"
    return "Pass: covering test green"


def main():
    if len(sys.argv) < 3:
        print("usage: rtm_coverage.py <report.xml> <req_id> [req_id ...]")
        sys.exit(1)

    report_path = sys.argv[1]
    required_ids = sys.argv[2:]
    coverage = parse_junit(report_path)

    print(f'{"Requirement ID":<10} {"Covering tests":<45} {"Result":<10} Verdict')
    for req_id in required_ids:
        results = coverage.get(req_id, [])
        test_ids = ", ".join(t for t, _ in results) or "(none)"
        last_result = results[-1][1] if results else "Not run"
        print(f"{req_id:<10} {test_ids:<45} {last_result:<10} {verdict(results)}")

    uncovered = [r for r in required_ids if not coverage.get(r)]
    if uncovered:
        print(f"\n{len(uncovered)} requirement(s) with zero covering tests: {', '.join(uncovered)}")


if __name__ == "__main__":
    main()

Run against a seven-test fixture report covering eight of this article's requirement IDs, it printed Pass for REQ-010, REQ-011, REQ-015, REQ-017, and REQ-019, Fail for REQ-012 (the same regression the backward-traceability walk found above), Stale for REQ-018 (the quarantined flaky test), and, at the bottom, a one-line summary calling out REQ-014 by name as having zero covering tests. That last line is the one no spreadsheet update produces on its own: the moment a requirement lands with a marker but no test behind it, the script says so, instead of leaving a blank cell for someone to notice later, or not.

A decision path over a JUnit report producing four coverage verdicts in the order the script checks them: no covering test in the report yields Gap and names REQ-014, any covering test that failed on its most recent run yields Fail and names REQ-012, every covering test skipped yields Stale and names REQ-018, and anything else yields Pass for REQ-010, REQ-011, REQ-015, REQ-017 and REQ-019, with a note at the bottom that the manual rows REQ-013 and REQ-016 never enter the report at all
The four verdicts in the order the script actually tests for them. REQ-014 is the only row that gets a verdict without a test to run, which is precisely the row a spreadsheet leaves blank.

Two honest limits, stated plainly. First, this only accounts for automated coverage; REQ-013 and REQ-016 are manual, and a JUnit report has nothing to say about a test a person ran by hand, so those rows still need a human to update them. Second, a Pass verdict here means "the covering test passed," not "the covering test checked the right thing," which is exactly the gap the next section is about.

How Autonoma keeps the coverage column current

The pattern above, a marker naming a requirement, a report carrying that marker to a result, a script turning that into a verdict, works because a JUnit report already exists and a human decided which requirement ID belongs to which test. Most of a codebase's actual behavior isn't wrapped in a marked test yet, and every new pull request adds routes and edge cases nobody has annotated with a requirement ID. Keeping a coverage column current still depends on someone remembering to add the marker first.

That's the piece our architecture is built to remove. Autonoma's agents read a codebase directly, the routes, components, and user flows that exist right now, and plan test cases against what the application actually does rather than what a marker says it should do. Those tests run against the real application in a live preview environment, so a coverage verdict reflects behavior actually exercised, not a report a human remembered to tag correctly. When a pull request changes the code, our Diffs Agent re-reads the diff and adds, updates, or deprecates test cases to match, the mechanism that keeps a coverage column from going stale the moment nobody's looking at it.

Map that onto the columns from earlier and the boundary is exact. Requirement ID, description, and source stay exactly what they were: a person's job, since only a person knows a clause exists in a contract or a control exists for compliance reasons. The columns to the right, whether a covering test exists, whether it passed, and what that means for the requirement, are what a codebase-reading agent can keep answering without anyone updating a spreadsheet. It doesn't replace the requirements management system that owns the left half of the table, and it doesn't decide what a requirement should say. It continuously answers the question the right half exists to answer.

Covered is not verified

Look back at REQ-010 and REQ-015. Both show TC-101 as their covering test, both show Pass, and both are legitimately, honestly covered. But TC-101 is one end-to-end run: sign up, pay, see a confirmation screen, cancel, see the confirmation dialog. That single test touching two requirements isn't a bug in the matrix, it's how end-to-end tests work, and it means the same green checkmark credited to REQ-010 also gets credited to REQ-015 without necessarily asserting anything specific about the cancellation dialog's wording, its button state, or whether canceling twice behaves correctly.

Scale that up. A handful of broad end-to-end flows can mark a dozen rows green while genuinely asserting the substance of only a few, which means a traceability matrix systematically overstates depth exactly where it looks most complete. REQ-019's "narrow assertion" verdict is the same failure mode from the other direction: the test confirmed the account's state flipped to read-only, and nothing else, saying nothing about whether a read-only account can still be billed, arguably the more important behavior.

None of this is a flaw in the technique. A row reading requirement to test to Pass proves a test exists and passed. It does not, and structurally cannot, prove the test exercises the requirement's substance, only that something ran and didn't fail. A test that passes without asserting anything meaningful looks identical in this matrix to a test that checked exactly the right thing, and the difference is a matter of what the test actually asserts, not whether it ran green. That limit is worth writing down next to a matrix like this one, not discovering the day an auditor asks a follow-up question the table can't answer.

When the traceability matrix goes stale

A hand-maintained matrix like the one above exists because linking requirements to tests used to be genuinely manual labour: someone read a spec, someone else wrote a test, and a third person, later, reconstructed which test covered which requirement from memory and a shared spreadsheet. That process produces a table wrong the day after it's written.

Some teams try to escape the spreadsheet by tracking this in Jira instead, but native Jira has no traceability matrix built in and won't tell you which requirements have zero covering tests on its own; the drift just moves from a cell nobody updates to a filter nobody built. A test gets renamed and the test case ID column silently points at nothing. Two tests merge into one and a row still lists the old ID. A requirement gets clarified in a Slack thread that never reaches the source column. None of those events throws an error. The cell just sits there, quietly wrong, until someone checks it by hand.

Two panels comparing a hand-maintained traceability matrix with a generated one. On the left, a spreadsheet grid labelled last edited 14 Feb connects to test TC-103 through a faded dashed arrow noting that the link decays quietly. On the right, a coverage query labelled re-run 6 minutes ago connects to the same TC-103 through a solid lime arrow noting the answer is re-asked on demand
Both panels point at the same test. The difference is that one answer was true the day it was typed, and the other is true for as long as it takes to re-run.

The generation script earlier in this article is a small, working answer to that drift for the coverage half of the table, but it's still something a person has to remember to run. The deeper shift is what it points at: once something can read the codebase and knows which tests exist against it, "which requirements have zero covering tests" stops being a table you maintain and becomes a question you ask, against whatever the code looks like right now. Autonoma operates in that second category for the tests it generates and runs, a meaningfully different model than updating a cell.

Traceability is a query, not a document. Every vendor page selling a matrix template would rather you didn't think about it that way, because a query doesn't need their spreadsheet.

For the surrounding process this matrix sits inside, see how it fits into the broader software testing life cycle and how it gets referenced in a test closure report at the end of a cycle. If your team is still deciding how much of this to formalize, that's a strategy question this article deliberately doesn't answer; our test plan template and entry and exit criteria pages do.

Requirements traceability solves a real problem: proving which requirements actually have tests behind them and which don't. Fill the table honestly, including the gaps, generate the half a machine can generate, and be specific about what a green cell does and doesn't prove. If you're evaluating whether Autonoma belongs in that picture, it belongs at the coverage columns, not the requirements management system.

Frequently Asked Questions

A requirements traceability matrix (RTM) is a table that links each requirement in a system to the artifacts that prove it was built and tested: a requirement ID, its description, the source it came from, the test case IDs that exercise it, whether those tests are automated, the result of their last run, and a coverage verdict summarizing whether the requirement is genuinely covered, stale, failing, or not tested at all.

At minimum, seven columns: a stable requirement ID, a short description, the source the requirement came from (a PRD section, a Jira epic, a compliance control, or a contract clause), the test case IDs covering it, whether each test is automated or manual, the result of its most recent run, and a coverage verdict derived from the columns to its left. Some teams add a defect ID column to support backward traceability from a bug back to the requirement it violated.

Usually a QA engineer, SDET, or test lead builds and maintains the matrix, drawing the requirement ID, description, and source from whoever owns requirements (a product manager or business analyst) and filling the test case, automation, and result columns from the team's own test suite. On regulated projects, a compliance or quality assurance role often reviews the completed matrix before an audit or release.

Forward traceability starts at a requirement and asks which tests exist for it, the direction used to check coverage. Backward traceability starts at a test result or a defect and asks which requirement it maps back to, the direction used to investigate a bug or to find out which feature area keeps producing defects. A complete matrix supports both directions from the same rows.

Yes, though it usually tracks user stories and acceptance criteria instead of formal requirements documents, and it gets rebuilt or re-queried every sprint rather than maintained as one static document across a long release cycle. Teams under contractual or regulatory obligations to prove coverage tend to keep a more formal RTM even inside an otherwise Agile process.

Test coverage, in the code-coverage sense, measures which lines, branches, or paths in the source code were executed while tests ran. A requirements traceability matrix measures something different: whether each business or product requirement has a test behind it at all, independent of which lines that test happens to execute. A feature can have 100% line coverage and still have a requirement with no test verifying its specific behavior, and a matrix is what surfaces that gap.

Yes, and that is the half that drifts. Autonoma's agents read your codebase directly, plan test cases against the routes, components, and flows that exist right now, and run them against a live preview environment, so the test case, last run, and coverage verdict columns describe behavior that was actually exercised rather than a marker somebody remembered to add. When a pull request changes the code, its Diffs Agent re-reads the diff and adds, updates, or deprecates cases to match, which is what keeps those columns answering honestly between releases instead of decaying quietly in a spreadsheet. The requirement ID, description, and source columns stay yours, because only a person knows a clause exists in a contract or a control exists for compliance reasons.

Related articles

A horizontal agent trajectory diagram showing a tool call passing a right-tool checkpoint but failing an argument-accuracy checkpoint

How to Test AI Agents That Take Actions (Tool Calls)

A runnable guide to testing tool-calling agents: right tool, right order, right arguments, mocked vs live calls, failure handling, and non-determinism.

A chatbot test pipeline moving from manual QA through scripted and semantic assertions into an automated CI gate that samples the model N times before allowing a merge

Chatbot Automation Testing: Why Assertions Fail

Chatbot automation testing that survives non-deterministic replies: the migration to a CI gate, n-run sampling, threshold gating, and real GitHub Actions YAML.

Sealed tenant data capsules being sorted into fully partitioned vault compartments, each isolated from the others, illustrating multi-tenant test data isolation

Multi-Tenant Test Data Isolation

What multi-tenant test data isolation means, why it matters for testing, and the four isolation patterns (schema, row-level, database, per-run) with tradeoffs.

A single disposable tenant boundary spun up inside one shared database, seeded, tested against, and then discarded, next to a separate full database fork labeled as a branch

What Is a Throwaway Tenant? (Disposable Tenants for Safe Testing)

A throwaway tenant is a disposable, isolated tenant created for one test run, then torn down. How it differs from a database branch.