How to build GREAT tests

@nicoespeon − ConFoo 2023

Test Tower

for elevators
in Zhongshan City, China

source

Why bother writing tests?

1

To catch regressions
when changing code

2

To have
faster feedbacks than manual tests

3

To help us design maintainable software

  • Easier to read
  • Less noise
  • More stability
  • Better asserts

Better tests

Better design

  • Domain vs. I/O
  • Easier to evolve
  • Fast, reliable tests
  • Property-based testing
  • Mutation testing
  • Approval testing
  • UI tests
  • Performance tests
  • Security tests
  • Accessibility tests

Things NOT covered

Who am I?

Freelance Web Dev

Nicolas Carlo

Legacy Code specialist 🧟

understandlegacycode.com

 

I organize Meetups 👥
Software Crafters, TypeScript, and React


Wanna reach out? 👉 @nicoespeon

Writing better tests

Our brain 🧠 loves patterns

How can we use that to make tests more readable?

1) Given/When/Then

it("returns fetched data on success", async () => {
  const data = { some: "data" }
  const fetch = async () => ({ data })
  const input = await resolveInput(fetch)
  expect(input.status).toBe("success")
  expect(input.value).toEqual(data)
});

1) Given/When/Then

it("returns fetched data on success", async () => {
  // Given
  const data = { some: "data" }
  const fetch = async () => ({ data })

  // When
  const input = await resolveInput(fetch)

  // Then
  expect(input.status).toBe("success")
  expect(input.value).toEqual(data)
});

It's OK to have multiple asserts
if you test one behavior

2) Craft custom asserts

it("returns fetched data on success", async () => {
  // Given
  const data = { some: "data" }
  const fetch = async () => ({ data })

  // When
  const input = await resolveInput(fetch)

  // Then
  expect(input).toHaveSucceededWith(data)
});

2) Craft custom asserts

it("returns fetched data on success", async () => {
  // Given
  const data = { some: "data" }
  const fetch = async () => ({ data })

  // When
  const input = await resolveInput(fetch)

  // Then
  expectInput(input).toHaveSucceededWith(data)
});

It can be as simple as extracting a function

3) Make tests failures clear

Don't trust a test you haven't see fail

4) Clarify tests labels

Let high-level concepts emerge

5) Clarify what's irrelevant

it("returns the seat that matches the given ID", () => {
  const seat = {
    id: "1",
    type: "seat",
    display_name: "A seat",
    has_amenities: true,
    popularity: 0
  };
  const bus = {
    driver: "left",
    cells: [[seat]],
  };

  const result = getSeatById(bus, "1");

  assert.deepEqual(result, seat);
});

5) Clarify what's irrelevant

it("returns the seat that matches the given ID", () => {
  const seat = {
    id: "1",
    type: "seat",
    display_name: "irrelevant",
    has_amenities: anyBoolean,
    popularity: anyNumber
  };
  const bus = {
    driver: "left",
    cells: [[seat]],
  };

  const result = getSeatById(bus, "1");

  assert.deepEqual(result, seat);
});

Don't pick a random value, make it clear

6) Remove the noise

it("returns the seat that matches the given ID", () => {
  const seat = {
    id: "1",
    type: "seat",
    display_name: "irrelevant",
    has_amenities: anyBoolean,
    popularity: anyNumber,
    amenities: anyList
  };
  const bus = {
    driver: "left",
    floors: [
      {
        level: 1,
        cells: [seat],
      },
    ],
  };

  const result = getSeatById(bus, "1");

  assert.deepEqual(result, seat);
});

6) Remove the noise

it("returns the seat that matches the given ID", () => {
  const seat = createAvailableSeat({ id: "1" });
  const bus = createBusWithSingleFloor([seat]]);

  const result = getSeatById(bus, "1");

  assert.deepEqual(result, seat);
});

Only expose what matters

Don't rely on defaults, provide what matters

7) The Builder Pattern

it("returns the seat that matches the given ID", () => {
  const seat = seatFactory()
  	.withId("1")
  	.makeAvailable()
  	.build();
  const bus = busFactory()
  	.withSingleFloorPlan([seat])
  	.build();

  const result = getSeatById(bus, "1");

  assert.deepEqual(result, seat);
});

Fluent, flexible API

8) Name Concepts

it("should notify auction was closed when 'close' message is received", () => {
  const { listener, translator } = setup();
  const message = new Message("SOL Version: 1.1; Event: CLOSE;");

  translator.processMessage(null, message);

  expect(listener.auctionClosed).toBeCalled();
});

What concept does `null` represent?

8) Name Concepts

it("should notify auction was closed when 'close' message is received", () => {
  const { listener, translator } = setup();
  const message = new Message("SOL Version: 1.1; Event: CLOSE;");
  const UNUSED_CHAT = null;

  translator.processMessage(UNUSED_CHAT, message);

  expect(listener.auctionClosed).toBeCalled();
});

It's an unused chat. Say it.

8) Name Concepts

const UNUSED_CHAT = null;

it("should notify auction was closed when 'close' message is received", () => {
  const { listener, translator } = setup();
  const message = new Message("SOL Version: 1.1; Event: CLOSE;");

  translator.processMessage(UNUSED_CHAT, message);

  expect(listener.auctionClosed).toBeCalled();
});

Can be pulled up, or moved to source code

9) Keep related code together

let listener, translator;

beforeEach(() => {
  listener = new FakeAuctionEventListener();
  translator = new AuctionMessageTranslator(
    "irrelevant sniper ID",
    listener,
    new FakeFailureReporter()
  );
});

// … 120 lines of code

it("should notify auction was closed when 'close' message is received", () => {
  const message = new Message("SOL Version: 1.1; Event: CLOSE;");

  translator.processMessage(UNUSED_CHAT, message);

  expect(listener.auctionClosed).toBeCalled();
});

Don't rely (too much) on before/after hooks

it("should notify auction was closed when 'close' message is received", () => {
  const { listener, translator } = setup();
  const message = new Message("SOL Version: 1.1; Event: CLOSE;");

  translator.processMessage(UNUSED_CHAT, message);

  expect(listener.auctionClosed).toBeCalled();
});

9) Keep related code together

It doesn't have to be 1 test per assert

let response, cartId = "irrelevant-cart-id";

before(async () => {
  response = await request().get(`/cart/${cartId}`).query({ currency: "EUR" });
});

it("should return 200", () => {
  assert.equal(response.status, 200);
});

it("should contain the currency", () => {
  assert.equal(response.body.data.currency, "EUR");
});

// … 15 lines of code

it("should have one passenger", () => {
  assert.equal(response.body.data.passengers.length, 1);
});

9) Keep related code together

Regroup in one test

Maybe craft a custom assert?

it("should retrieve a one-passenger Cart in the given currency", async () => {
  const cartId = "irrelevant-cart-id";

  const response = await request()
    .get(`/cart/${cartId}`)
    .query({ currency: "EUR" });

  assert.equal(response.status, 200);
  assert.equal(response.body.data.currency, "EUR");
  assert.equal(response.body.data.passengers.length, 1);
});

9) Keep related code together

Tests are implicitly coupled… 

let response, cartId = "irrelevant-cart-id";

before(async () => {
  response = await request().get(`/cart/${cartId}`));
});

// … 30 lines of code

it("should select a seat", async () => {
  const query = { passengerId: response.passengers[0].id, seatId: "1" };

  const result = await request().put(`/cart/${cartId}/select-seat`).send(query);

  assert.equal(result.body.data.selected_seat, "1");
});

9) Keep related code together

Retrieve context data in the "Given" phase…

it("should select a seat", async () => {
  const cartId = "irrelevant-cart-id";
  const cart = await request().get(`/cart/${cartId}`);
  const query = { passengerId: cart.passengers[0].id, seatId: "1" };

  const result = await request().put(`/cart/${cartId}/select-seat`).send(query);

  assert.equal(result.body.data.selected_seat, "1");
});

9) Keep related code together

… and reduce the noise

it("should select a seat", async () => {
  const { cartId, passengers } = await fetchCart();
  const seatId = "1";

  const result = await request()
    .put(`/cart/${cartId}/select-seat`)
    .send({ passengerId: passengers[0].id, seatId });

  assertSelectedSeatIs(result, seatId);
});

9) Keep related code together

10) Put tests close to the code

Keep related code together

Reduces Shotgun Surgery
Easier to delete

10) Put tests close to the code

No need to map 1:1
test & code structure
💡

11) Cleanup DB before each test

describe("createBillingAddress", () => {
  beforeEach(async () => {
    await cleanUpDB();
  });

  it("creates a BillingAddress with given address and CPF", async () => {
    const address = addressBuilder().atStreet("6465 Amazing Street").build();
    const billingIds = [createCPF("1234")];

    const result = await createBillingAddress(address, billingIds);

    assert.equal(result.address1, "6465 Amazing Street");
    assert.equal(result.cpf_number, "1234");
    assert.equal(await BillingAddressTable.count(), 1);
  });
});

Each test is independent
If a test fail, you can inspect the data

12) Don't tolerate flaky tests

👉 Fix them, or delete them

A test you can't trust
is worse than no test at all

Usual suspects:   Time

                               Async

                               Randomness

                               Coupling

13) Think ZOMBIES

Zero
One

Many

Boundaries
Interface Definition
Exceptions
Simple Scenarios

source

Improving software architecture

Legacy Code

"To me, legacy code is simply code without tests."
M. Feathers

Capture what the code does!

See Approval Testing

 

aka Characterization Tests

aka Snapshot Tests

aka Golden Master

aka Regression Tests

aka Locking Tests…

  • Slow tests
  • Flaky tests
  • Tests in the way of refactoring
  • Confusion about conflicting
    testing strategies #Twitter #LinkedIn

When tests are PAINFUL

People have ≠ definitions for unit/integration tests

Pyramid, Trophy, Crab, Honeycomb…
source

What really matters

Tests & Software Architecture

 

3. Most Domain tests, but coarse-grained

3a. Most tests shouldn't prevent refactoring

3b. low-level unit tests to help implement something, can be thrown away

3c. replace actual I/O with fake implementation

4. Need some integrated (contract) tests to remove the blind spots

What about existing code though?

 

1. Most likely to be your case

2. Identify the I/O, introduce a Seam

2a. Eg. extract method + subclass to test

3. Fine to change code for sake of testing until we are done (safety >> purity)

4. Move to delegate

  • Time (Clock)
  • Async (Network, Database)
  • Randomness

What makes it hard?

Cause slowness & flakiness

This is the I/O aka Infrastructure
What's left is Pure Logic aka Domain

The magic trick

1. Separate Infrastructure from Domain

2. Make Domain not depend on Infrastructure

See Hexagonal Architecture

and Clean Architecture

and Onion Architecture

and A-Frame Architecture

and Functional Core, Imperative Shell… no really, people like to give different names to the same concept… 🤷‍♂️

A better architecture…

Need a workshop on Universal Architecture, Tests, and Legacy Code?

👉 ping me

  • A good % of your app
  • Easy to test!
  • Fast & Reliable

Test your Domain code

But avoid these mistakes…

  • Useless interfaces/mocks
  • Tests that are too narrow
  • Misunderstanding your testing strategy

These can help

💸 30% OFF with
this link

Now, go write GREAT tests!

@nicoespeon to stay in touch

Build GREAT tests

By Nicolas Carlo

Build GREAT tests

Your tests may be good. But they could be better! When production code has tests, they are often hard to read. They also get in your way when you want to refactor code. Sometimes, you have false positives. Often, they are flaky… In this talk, I will show you real-world examples of tests that have been improved. I will cover a few techniques you can re-use at work to improve the quality of your own tests!

  • 742