Mert Özen Further With Every Line
Project Management SDLC (Software Development Life Cycle) — #03

The V-Model: Pairing Every Development Step with a Test Level

Mert Özen Nov 8, 2026 8 min 3 views
The V-Model: Pairing Every Development Step with a Test Level

In the V-Model testing isn't done last, it's designed on the left arm: requirements pair with acceptance tests, system design with system tests, architecture with integration tests, module design with unit tests. We tie Bookstore's 14-day return rule to its requirement with xUnit v3 traits, list the four tests of REQ-RET-01 with one command, and see in real test output which requirement a boundary bug breaks.

Version note: The code in this post was built and run with .NET 10, xUnit v3 4.0.2 and Microsoft.AspNetCore.Mvc.Testing 10.0.14.

Guess First

Most developers looking at the classic drawing of the V-Model reach this conclusion: design happens on the left arm, code is written at the bottom, and testing happens step by step on the right arm. So testing is still last. Now guess: according to the V-Model, when should the acceptance test for Bookstore's returns feature be written? After the code is done, after the system test, or at some other time?

The right answer is "while the requirements are being written". The horizontal lines between the two arms of the V show what this model really says, and in most drawings they are the thinnest lines.

Where Did the V-Model Come From?

The V-shaped model has two separate roots. One is the German V-Modell: it was developed in the mid-1980s by IABG (Industrieanlagen-Betriebsgesellschaft) for the German Ministry of Defense, and later became the standard for IT (information technology) projects of German public bodies. V-Modell XT (eXtreme Tailoring) replaced the V-Modell 97 version in February 2005. The other root is in systems engineering: in 1991 Kevin Forsberg and Harold Mooz presented a paper at the NCOSE (National Council on Systems Engineering) symposium that explained the relationship between systems engineering and the project cycle with a V shape.

Both roots arrive at the same idea: while decomposing the system top-down (the left arm), answer the question "how will I verify this?" at every level, and while integrating the system bottom-up (the right arm), test every level against that answer.

Verification and Validation

Two words are the key to understanding the V-Model. In Barry Boehm's often-quoted formulation, verification asks "are we building the product right?" and validation asks "are we building the right product?"

Verification checks whether the output of a step matches that step's input: does the module behave as the design says, do the components talk to each other as the architecture draws it? Validation asks whether the product meets the real need: can the customer actually make a return, and is this what they wanted? A product can pass every verification step and still fail validation. A feature whose requirements were implemented flawlessly but written wrongly is exactly that.

03-v-model-en.png

Four Pairs: In Bookstore's Returns Feature

Requirements pair with the acceptance test. Requirement (REQ-RET-01): "A customer can turn a delivered order into a return request within 14 days after delivery." While the requirements are written, two acceptance criteria (AC) are written too: AC-1, a request within the window is accepted and returns 201; AC-2, a request outside the window is rejected with 422 and a readable message. These two sentences are the design of the acceptance test.

System design pairs with the system test. System design says how the return flow works end to end: the request is received, a record is created, a refund message is sent. The system test exercises this flow with real components in an environment similar to production.

Architecture design pairs with the integration test. The architecture says which services and which database the endpoint uses; the integration test verifies that these parts really work together. In the example below, this level is tested with WebApplicationFactory, which starts the application in memory.

Module design pairs with the unit test. The module design gathers the return rule in one place; the unit test exercises the boundaries of that rule. Is day 14 included or not? If module design doesn't answer this question, the unit test can't be written either.

The depth of test levels, that is, which behavior should be tested at which level, is in the Test Strategy series post "What to Test at Which Level". What we focus on here is the pairing itself and how to keep it traceable in code.

Traceability in Code: From Requirement to Test

The output of module design is a small class that carries the rule on its own:

namespace Bookstore.Returns;

// REQ-RET-01: a customer can request a return within 14 days of delivery
public static class ReturnPolicy
{
    public static readonly TimeSpan Window = TimeSpan.FromDays(14);

    public static bool CanReturn(DateTimeOffset deliveredAt, DateTimeOffset now) =>
        now - deliveredAt <= Window;
}

The unit tests exercise the boundary and carry the requirement they belong to as an xUnit trait:

public class ReturnPolicyTests
{
    private static readonly DateTimeOffset Delivered = new(2026, 11, 1, 12, 0, 0, TimeSpan.Zero);

    [Fact]
    [Trait("Requirement", "REQ-RET-01")]
    public void CanReturn_OnDay14_IsAllowed() =>
        Assert.True(ReturnPolicy.CanReturn(Delivered, Delivered.AddDays(14)));

    [Fact]
    [Trait("Requirement", "REQ-RET-01")]
    public void CanReturn_OneMinuteAfterDay14_IsRejected() =>
        Assert.False(ReturnPolicy.CanReturn(Delivered, Delivered.AddDays(14).AddMinutes(1)));
}

The integration tests call the endpoint through its real pipeline. To pin the time, we replace TimeProvider inside the test, so the test gives the same result every day regardless of today's date. Every test carries both the requirement and the acceptance criterion it verifies:

public sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => now;
}

public class ReturnEndpointTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    private HttpClient CreateClient() =>
        factory.WithWebHostBuilder(b => b.ConfigureServices(s =>
            s.AddSingleton<TimeProvider>(new FixedClock(new DateTimeOffset(2026, 11, 8, 9, 0, 0, TimeSpan.Zero)))))
        .CreateClient();

    [Fact]
    [Trait("Requirement", "REQ-RET-01")]
    [Trait("Acceptance", "AC-2")]
    public async Task Post_AfterWindow_Returns422()
    {
        HttpResponseMessage response = await CreateClient().PostAsync("/orders/1002/returns", null, TestContext.Current.CancellationToken);
        Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
    }
}

The Post_WithinWindow_Returns201 test for AC-1 is written the same way. Now the real benefit of traceability: we can list every test tied to a requirement, at whatever level, with a single command. Because xUnit v3 test projects are standalone applications, we can pass runner options directly:

$ dotnet run --project tests/Bookstore.Returns.Tests -- -list tests -trait "Requirement=REQ-RET-01"
xUnit.net v3 In-Process Runner v4.0.2+0c7aa4991f (64-bit .NET 10.0.13)

Bookstore.Returns.Tests.ReturnEndpointTests.Post_AfterWindow_Returns422
Bookstore.Returns.Tests.ReturnEndpointTests.Post_WithinWindow_Returns201
Bookstore.Returns.Tests.ReturnPolicyTests.CanReturn_OnDay14_IsAllowed
Bookstore.Returns.Tests.ReturnPolicyTests.CanReturn_OneMinuteAfterDay14_IsRejected

When we run the application and try the situation AC-2 describes by hand, the response for order 1002, delivered on October 1, is this:

$ curl -s -i -X POST http://127.0.0.1:5481/orders/1002/returns
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 08:58:02 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Return window closed","status":422,"detail":"Order 1002 was delivered more than 14 days ago."}

Collecting these pairs in a table makes the horizontal lines of the V concrete:

Requirement / criterionV levelTest
REQ-RET-01, AC-1: request within window returns 201Acceptance (automated via integration)Post_WithinWindow_Returns201
REQ-RET-01, AC-2: request outside window returns 422Acceptance (automated via integration)Post_AfterWindow_Returns422
REQ-RET-01: day 14 includedUnitCanReturn_OnDay14_IsAllowed
REQ-RET-01: 14 days + 1 minute excludedUnitCanReturn_OneMinuteAfterDay14_IsRejected

In the first two rows of the table we automated the acceptance criteria at the integration test level. This is a shortcut: real acceptance happens when the customer or the Product Owner tries the feature with their own scenario. The automated test is the part of that check that can be repeated on every deploy. WebApplicationFactory itself is covered in detail in the ASP.NET Core Web API (Application Programming Interface) series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory".

How Does a Boundary Bug Lead Back to a Requirement?

The value of traceability shows up when something breaks. During a refactoring someone changes <= to <; the code compiles and the change looks innocent. The test run says this:

failed Bookstore.Returns.Tests.ReturnPolicyTests.CanReturn_OnDay14_IsAllowed (0ms)
  Assert.True() Failure
  Expected: True
  Actual:   False

Test run summary: Failed!
  total: 4
  failed: 1
  succeeded: 3

The broken test's trait is REQ-RET-01. The reviewer no longer says "a test broke" but "we broke the requirement that day 14 is included". The integration tests didn't catch this bug, because the dates we pinned were not exactly on the boundary. This shows why the lower levels of the V are necessary: boundary values are cheapest to exercise at the unit level.

The V-Model in Regulated Industries

The V-Model has lived longest in industries where proving to an auditor that every requirement is paired with a test is mandatory. For medical device software, IEC (International Electrotechnical Commission) 62304 describes the software life cycle processes and how they are documented; an amendment to the 2006 standard was published in 2015. In automotive, Automotive SPICE (Software Process Improvement and Capability dEtermination), published by VDA (Verband der Automobilindustrie) QMC (Qualitäts Management Center) with version 4.0 released in 2023, is used to assess suppliers' process capability and explicitly looks for the pairing between the left and right arms.

The details of these standards are not the subject of this post. What matters is this: in these industries traceability is not a preference but a condition for bringing the product to market. That is why methods that generate the pairing from code automatically, like the trait approach above, are far more reliable than tables kept by hand. As the tests change, the table stays current on its own.

Production Notes

Manage requirement IDs from a single place. If requirements live in a backlog tool, the ID in the test trait must be the same as the ID in that tool; otherwise traceability stays on paper. Filtered runs by trait in the CI (Continuous Integration) pipeline also let you quickly run only the relevant tests when a specific requirement changes.

Record the ambiguity of a requirement while designing its test. If a requirement says "14 days" but doesn't specify the time zone or from which moment of the day to count, the person trying to write the unit test will be the first to ask. That is the real payoff of designing tests on the left arm: ambiguity is caught before code is written.

Don't bind directly to the system clock when testing a time-dependent rule. An integration test written without an abstraction like TimeProvider will one day turn red on its own as the calendar moves on.

Common Pitfalls

The most common pitfall is reading the V-Model as "testing comes last". The right arm of the model shows test execution, not test design. A team that doesn't design acceptance tests together with requirements isn't applying the V, it's applying a Waterfall whose two arms are not connected.

The second is thinking the V-Model itself is a different life cycle from Waterfall. The classic V-Model is sequential too; feedback still arrives late. The model's contribution is pulling test design forward; it doesn't add iteration. Most regulated teams today apply the V not as one big pass but as small Vs repeated in every increment.

The third is applying the pairing as a one-to-one obligation. Not every requirement needs a test at every level; some rules only make sense at the unit level, some flows only at the system level.

The fourth is maintaining the traceability table by hand. A hand-kept table is outdated by the end of the first sprint; it gets rewritten the week before the audit and reassures no one.

A Small Experiment

★☆☆ Pick a requirement from your team's last feature and answer the question "what would I test at this level?" for each of the four levels. Which level stayed empty?

★★☆ Add REQ-RET-02 for partial returns to the example above: only two books of a five-book order can be returned. Write the acceptance criteria first, then the tests with traits, and the code last. How many tests does the -trait "Requirement=REQ-RET-02" filter list?

★★★ Write a small script that reads the tests discovered by the test project together with their traits and produces a traceability table in Markdown (the -list full output is a good starting point). What do you gain in an audit if you store this table as a CI artifact?

The V-Model pulled testing forward but still left the process as a single pass. In the next post we look at the models that turn the process into a loop: the difference between incremental and iterative development, Boehm's risk-driven Spiral model, and Bookstore's checkout starting with a walking skeleton.