Beyond Pass/Fail: Writing Tests That Tell a Story
Testing is often seen as a necessary evil - a gatekeeper that ensures code works as intended before it’s released into the wild.
But what if we could shift our perspective on testing? What if, instead of viewing tests as mere pass/fail checkpoints, we saw them as a way to tell a story about our code? A story that not only verifies functionality but also communicates intent, documents behavior, and guides future development.
Why your tests need a narrative
Most developers are familiar with the basic pattern:
test("add numbers", () => {
expect(add(2, 2)).toBe(4);
});
While functional, this test tells us very little. What's the broader context? What edge cases should we consider? Why is this behavior important?
By writing tests that tell a story, we can make tests more meaningful.
A beginning, a middle, and an end.
When writing stories, it's common knowledge to structure them with a beginning, a middle, and an end.
Tests are no different, and we can use the Arrange-Act-Assert pattern to represent this structure:
test("user receives loyalty discount after their tenth purchase", () => {
// Arrange: Set up a customer's purchase history
const customer = createCustomer({
purchaseHistory: createPurchases(9),
loyaltyStatus: "standard"
});
// Act: Make the tenth purchase
const receipt = customer.makePurchase(createOrder(50.00));
// Assert: Verify loyalty discount was applied
expect(receipt.appliedDiscounts).toContain("loyalty");
expect(receipt.totalAmount).toBe(45.00); // 10% loyalty discount
});
Stick to the plot
After years of being told don't repeat yourself, it can be tempting to test multiple things in one test.
However, just like a story, this can lead to confusion over what the test is about. Instead of adding extra assertions to one test, create a new test for each scenario your application supports:
describe("password reset email", () => {
test("has correct subject and body", () => {
const resetEmail = generatePasswordResetEmail("user@example.com");
expect(resetEmail.subject).toInclude("Password Reset");
expect(resetEmail.body).toInclude("If you didn't request this reset");
});
test("contains secure, time-limited recovery link", () => {
const resetEmail = generatePasswordResetEmail("user@example.com");
expect(resetEmail.recoveryLink).toContain("https://");
expect(resetEmail.recoveryLink).toIncludeToken();
expect(resetEmail.tokenExpiration).toBeLessThan(24 * 60 * 60);
});
});
Paint a picture
A good storyteller uses descriptive language to paint a picture of what's happening.
Instead of generic names like test addition
or check user
, use names that describe the scenario and expected outcome.
When creating test data, use values that make sense in the domain. Instead of user1
or test@test.com
, use names and values that reflect real scenarios.
test("senior citizen receives 15% discount on weekday matinee ticket", () => {
const customer = createCustomer({
name: "Margaret Thompson",
age: 72,
membershipType: "standard"
});
const showtime = createShowtime({
movie: "The Grand Budapest Hotel",
day: "Wednesday",
time: "14:30"
});
const ticket = customer.purchaseTicket(showtime);
expect(ticket.price).toBe(8.50); // Regular price $10, with 15% discount
expect(ticket.appliedDiscounts).toContain("Senior Weekday Matinee");
});
Plot twists
Every good story has unexpected turns.
Similarly, tests should explore edge cases, error conditions, and unexpected inputs. This not only improves robustness but also documents how the system handles unusual situations:
describe("shopping cart total calculation", () => {
test("handles mixed currency items by converting to shop's base currency", () => {
const cart = new ShoppingCart({ baseCurrency: "USD" });
cart.add({ price: 10.00, currency: "EUR" });
cart.add({ price: 15.00, currency: "USD" });
expect(cart.total).toBe(27.00); // Assuming 1 EUR = 1.2 USD
});
test("removes items with zero quantity during checkout", () => {
const cart = new ShoppingCart();
cart.add({ item: "widget", quantity: 1 });
cart.updateQuantity("widget", 0);
expect(cart.isEmpty()).toBe(true);
});
});
The end
Well-written tests are more than validation tools - they're documentation that stays in sync with your code. By treating tests as stories, you create a valuable resource for your team that captures not just what the code does, but why it does it that way.
Remember, future developers (including yourself) will thank you for tests that explain themselves clearly. Take the time to craft test narratives that guide understanding and make maintenance easier.
With Carbonate you can generate and run automated, self-healing tests just by using your application.