A pattern that spans two layers needs tests in two layers. That’s the obvious part.

The less obvious part is what those tests reveal about the design. When a pattern is hard to test, it’s usually because the boundaries aren’t clean. The recipe pattern has well-defined seams: the JSON contract, the parser mapping, and the validator output. Each one is a natural test point.

This is the final post in the series. Part 1 covered the why, Part 2 covered the frontend consumer, and Part 3 covered the backend producer. This post is about how we know it all works.

Four Layers, Four Purposes

The pattern has four distinct test layers, each serving a specific purpose.

1. Validator Unit Tests

These are the fastest and most focused tests in the suite. Each test breaks exactly one rule, verifies one error, and runs in milliseconds.

[Fact]
[Trait("Category", "Fast")]
public void invoice_date_is_required()
{
    given_a_command_with_no_invoice_date();
    when_validating();
    then_error_IS_reported_on(_ => _.InvoiceDate);
}

[Fact]
[Trait("Category", "Fast")]
public void invoice_date_in_a_closed_period()
{
    given_a_command_with_invoice_date_in_a_closed_period();
    when_validating();
    then_error_IS_reported_on(_ => _.InvoiceDate);
}

[Fact]
[Trait("Category", "Fast")]
public void invoice_date_in_an_open_period()
{
    given_a_command_with_invoice_date_in_an_open_period();
    when_validating();
    then_error_is_NOT_reported_on(_ => _.InvoiceDate);
}

The lambda selector (_ => _.InvoiceDate) over a plain string is intentional. It’s compile-time safe: if the property is renamed during a refactor, the test breaks at compile time, not at runtime.

These tests use mocked repositories. The goal is validator logic only, with no database round-trips. They run in the tens of milliseconds and give instant feedback on rule changes. The test harness also supports more complex scenarios that require database queries (slower, but valuable, integration tests).

2. Recipe Endpoint Tests

The recipe is tested through the GET endpoint that serves it. The test GETs the endpoint and verifies the full response matches a contract file:

[Fact]
[Trait("Category", "Slowest")]
public void valid_recipe()
{
    given_an_invoice();
    given_user_is_authorized();
    when_GETing();
    then_response_matches("expected_response");
}

The expected_response.json file is the contract. It includes entity data, readOnlyFields, and validationRules.fields — the full shape the frontend will consume:

{
  "succeeded": true,
  "data": {
    "entity": { ... },
    "readOnlyFields": [],
    "validationRules": {
      "fields": {
        "Invoice.Date": { "required": {} },
        "Invoice.ReferenceNumber": {
          "required": {},
          "limitedLength": { "min": 0, "max": 50 }
        },
        "Invoice.TypeId": {
          "required": {},
          "options": { "1": "Service", "2": "Freight" }
        }
      }
    }
  }
}

This is where the boundary gets enforced in both directions. Backend-only validations should never appear in the validationRules.fields section. If one leaks in, the response won’t match the contract, and the test fails. If the extraction layer breaks due to a FluentValidation API change, the validationRules section changes shape, and the test fails. The contract file is the specification. This is a slow integration test that earns its keep with the value it brings.

3. Frontend Parser and Form Model Tests

The seam between backend and frontend is the field name mapping. The parser translates Invoice.ReferenceNumber to referenceNumber. If this mapping breaks (a prefix changes, a field is renamed, a new prefix is added), the frontend stops receiving validation for that field. Silently.

Parser tests verify each mapping explicitly:

describe('ValidationRulesParser', () => {
  let ctx: Context;
  beforeEach(() => { ctx = new Context(); });

  describe('strips the command prefix from field names', () => {
    scenario([
      () => ctx.given_validation_rules_with_invoice_prefix(),
      () => ctx.when_parsing_the_rules(),
      () => ctx.then_referenceNumber_field_IS_present(),
      () => ctx.then_prefixed_field_name_is_NOT_present()
    ]);
  });

  describe('rewrites field names in conditional required when-clauses', () => {
    scenario([
      () => ctx.given_validation_rules_with_conditional_required(),
      () => ctx.when_parsing_the_rules(),
      () => ctx.then_the_when_clause_field_name_is_stripped()
    ]);
  });
});

Form model tests verify that the right validator type comes out for each rule: a required rule produces Validators.required on the control, a limitedLength rule produces Validators.maxLength, and a conditional required rule produces a group validator on the parent FormGroup rather than a field validator.

These catch the category of bug where the recipe is correct, and the validation code is correct, but the mapping between them is wrong.

4. End-to-End Tests

E2E tests tell the story from the user’s perspective. Three scenarios cover the minimum for any recipe-driven form:

describe('Invoicing', () => {
  describe('Recording an invoice', () => {
    describe('required field hint is shown before submitting', () => {
      scenario([
        () => ctx.given_I_am_creating_an_invoice(),
        () => ctx.when_I_try_to_record_the_invoice_without_a_date(),
        () => ctx.then_I_see_a_required_hint_on_the_date_field()
      ]);
    });

    describe('server error is shown on the affected field', () => {
      scenario([
        () => ctx.given_I_am_creating_an_invoice(),
        () => ctx.when_I_record_the_invoice_with_a_date_in_a_cloed_period(),
        () => ctx.then_I_see_the_closed_period_error_on_the_date_field()
      ]);
    });

    describe('invoice is recorded successfully', () => {
      scenario([
        () => ctx.given_I_am_creating_an_invoice,
        () => ctx.when_I_record_the_invoice(),
        () => ctx.then_I_see_the_invoice_in_the_list()
      ]);
    });
  });
});

Step names describe human intent, not UI mechanics. The function body contains the UI mechanics. The step name contains only what the person is doing. This keeps the test narrative readable and resilient to UI changes. If the submit button becomes a keyboard shortcut or a gesture, the step name stays the same.

The Tests That Made the Trade-Off Safe

In Part 3, I mentioned that depending on FluentValidation for both enforcement and extraction means depending on its internal structure. When the library released a major version with API changes, the extraction layer needed updates.

The tests caught every breakage. Validator unit tests flagged enforcement failures. Recipe endpoint tests flagged extraction failures: the expected_response.json contract files no longer matched. The scope of the change was immediately clear, the fixes were targeted, and nothing slipped through.

This is the less-obvious value of testing a cross-layer pattern. The tests don’t just verify behavior; they define the contract between the layers. When a library changes, a rule is renamed, or a new field is added, the tests tell you exactly what changed and where.

Closing the Series

This series started with a problem: validation logic in two places, drifting, fragile. It moved through the design decision (backend owns the rules), the consumer implementation (frontend reads and follows), the producer implementation (one validator, two jobs), and finally here: the tests that hold it together.

The pattern isn’t complicated. The value is in the boundaries: what the backend declares, what the frontend receives, what the tests verify. Each boundary is explicit. Each one can be tested independently. And when something changes in the codebase, in a library, or in a business rule, the tests tell you where.


Building and maintaining this pattern is a story about trust. The frontend trusts the backend to describe the rules accurately. The backend trusts the frontend to apply them faithfully. The tests are what make that trust concrete.

Leave a Reply

Trending

Discover more from Claudio Lassala's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading