Assertion Chains: Thinking in Behaviors, Not Implementation

Part 3 of 9 in the series: Unit Testing — A Behavior-First Approach

This post is part of a weekly series exploring how behavior-first testing transforms how we build and maintain code. Each post can be read independently, but they build on each other.

Reading Time: ~5 minutes

Quick Context: Where We Are

In earlier posts, we solved the setup problem with builders and test DSLs. Now we're tackling the other half of brittle tests: how we verify outcomes. If builders let us describe test data clearly and resiliently, assertion chains do the same for verification — centralizing how we check that behaviors actually happened.


In Blog Post 1, we saw how brittle tests broke on every change. In Blog Post 2, we solved the setup problem with Builders and Test DSL. But there's another piece of the puzzle: assertions.

Just like direct instantiation created brittle setup, direct property assertions create brittle verification. Let me show you how assertion chains let us think in behaviors, not implementation details.

The Problem: Asserting Implementation Details

Let's revisit our Task example. After we fixed the setup with builders, we still had assertions like this:

it('should retrieve a task by id', async () => {
  const testDsl = TaskTestDsl();

  /**
     * Given an existing task in the system
     */
  const task = testDsl.generate.task().withId('task-123').build();

  /**
     * When an employee retrieves the task by its ID
     */
  const result = await taskService.getTaskById('task-123');

  /**
     * Then the existing task will be returned
     */
  expect(result.id).toBe(task.id);
  expect(result.title).toBe(task.title);
  expect(result.status).toBe(task.status);
  expect(result.assignedTo).toBe(task.assignedTo);
});

These assertions are checking properties, not behaviors. They're tightly coupled to the structure of the Task object. If we change how tasks are serialized, or if we add computed properties, these assertions break — even when the behavior we're testing hasn't changed.

The Solution: Assertion Chains

Assertion chains let us express what we're verifying, not how. Instead of checking individual properties, we assert on behaviors:

describe('Feature: Retrieve Task by ID', () => {
  it('Scenario: Task exists', async () => {
    const testDsl = TaskTestDsl();

    /**
     * Given an existing task in the system
     */
    const task = testDsl.generate.task().withId('task-123').build();

    /**
     * When an employee retrieves the task by its ID
     */
    const result = await taskService.getTaskById('task-123');

    /**
     * Then the existing task will be returned
     */
    testDsl.assert.task(result).exists().and().isEqualTo(task);
  });
});

Notice the difference: we're asserting that the result exists and is equal to the original task. We're not checking individual properties. The assertion chain handles the details.

The Normalize Function: Handling Models and Data

One of the most powerful features of assertion chains is the normalize function. It lets us work with both model instances and data representations seamlessly:

// tests/test-dsl/assert/task.assertion.ts
export const configureTaskAssertionChain = (
  input: Task | TaskData
): TaskAssertionChain => {
  const { m, dto } = normalize(input);
  const chain: TaskAssertionChain = {
    exists: function (): TaskAssertionChain {
      expect(m).toBeDefined();
      expect(dto).toBeDefined();
      expect(m).not.toBeNull();
      expect(dto).not.toBeNull();
      expect(m).toBeInstanceOf(Task);
      return chain;
    },
    isEqualTo: function (other: Task | TaskData): TaskAssertionChain {
      const { m: otherM, dto: otherDto } = normalize(other);
      expect(dto).toStrictEqual(otherDto);
      return chain;
    },
    hasStatusOfInProgress: function (): TaskAssertionChain {
      expect(m.isInProgress()).toBe(true);
      return chain;
    },
    // ... more behavior-focused methods
  };
  return chain;
};

function normalize(input: Task | TaskData): { m: Task; dto: TaskData } {
  if (input instanceof Task) {
    return { m: input, dto: input.toData() };
  }
  return { m: Task.fromData(input), dto: input };
}

The normalize function accepts either a Task instance or TaskData object and returns both the model (m) and data transfer object (dto). This means our assertions work whether we're comparing two Task instances, two TaskData objects, or a mix.

Behavior-Focused Assertions: Testing What, Not How

Compare the assertion approaches:

Before (Implementation-focused):

expect(result.id).toBe(task.id);
expect(result.status).toBe(task.status);

After (Behavior-focused):

testDsl.assert
  .task(result)
  .hasId('task-123')
  .and()
  .hasStatusOfInProgress()
  .and()
  .isAssignedTo('user-456');

The behavior-focused version is more readable and expresses intent. It's also more resilient — if the internal representation changes, but the behaviors remain the same, the assertions don't need to change.

When Requirements Evolve

Imagine we need to integrate with another system that uses "STARTED" instead of "IN_PROGRESS." With behavior-focused assertions, our tests don't need to change:

// Model encapsulates the complexity
isInProgress(): boolean {
  return this.status === 'IN_PROGRESS' || this.status === 'STARTED';
}

Tests that use hasStatusOfInProgress() still work because they're testing behavior, not enum values. If we had used expect(result.status).toBe('IN_PROGRESS'), we would have had to update every test.

Real-World Example: Round-Trip Serialization

describe('Technical: Marshall to and from Data Representation', () => {
  it('Scenario: Task is converted to Data Representation and back', () => {
    const testDsl = TaskTestDsl();

    /**
     * Given a high-priority, in-progress task
     */
    const original = testDsl.generate
      .task()
      .withId('task-123')
      .withStatus('IN_PROGRESS')
      .withPriority('HIGH')
      .build();

    /**
     * When the task is serialized to JSON and reconstructed
     */
    const json = JSON.stringify(original.toData());
    const parsed = JSON.parse(json);
    const roundTripped = Task.fromData(parsed);

    /**
     * Then the round-tripped task matches the original
     * in both behavior and data shape
     */
    testDsl.assert
      .task(roundTripped)
      .exists()
      .and()
      .isEqualTo(original)
      .and()
      .isEqualTo(parsed)
      .and()
      .hasStatusOfInProgress()
      .and()
      .hasStatusInData('IN_PROGRESS')
      .and()
      .hasPriorityInData('HIGH');
  });
});

We can assert on both model behavior (what the task is) and data shape (what enum values are stored) in the same assertion chain.

The Complete Picture

We've now solved both halves of the brittle test problem:

  1. Setup (Blog Post 2): Builders centralize test data generation
  2. Assertions (This Post): Assertion chains centralize outcome verification

Together, they create tests that only change when behavior changes, express intent clearly, are resilient to implementation changes, and document behavior rather than implementation.

What's Next

We've covered how to write resilient tests at the unit level. But there's another important question: where should we test? In the next post, we'll explore testing at the layer of behavior — domain, application, and infrastructure. We'll see how testing the right layer reduces test count while increasing confidence.