How to build GREAT tests
Slides: bit.ly/build-great-tests
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 🧟
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: Seat = {
id: "1",
type: "seat",
display_name: "A seat",
has_amenities: true,
popularity: 0
};
const bus: 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: Seat = {
id: "1",
type: "seat",
display_name: "irrelevant",
has_amenities: anyBoolean,
popularity: anyNumber
};
const bus: 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: Seat = {
id: "1",
type: "seat",
display_name: "irrelevant",
has_amenities: anyBoolean,
popularity: anyNumber,
amenities: anyList
};
const bus: 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
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
Now, go write GREAT tests!
@nicoespeon to stay in touch
Slides: bit.ly/build-great-tests
Building GREAT tests (TS Meetup)
By Nicolas Carlo
Building GREAT tests (TS Meetup)
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!
- 81