Component tests
In short: A scenario is for the examples the business agreed on and wants to read. A component test calls the code directly: ten amounts in milliseconds, with no steps and no Gherkin. Both check one rule for different readers. The scenarios stay few, and the component tests take on the technical cases.
Problem
Fifteen scenarios for one boundary
The programmer decided to check delivery properly: 1 cent, 10 €, 49.49 €, 49.50 €, 49.99 €, 50 €, 50.01 €, 100 €, 250 €, a negative amount. He wrote everything down as scenarios.
The shop owner opened the scenarios file to see the delivery rule. She saw fifteen nearly identical scenarios and closed the file. The living documentation was no longer being read.
The idea
The test calls the code directly
From the programming course you know component tests: a method that calls the code and compares the result. The delivery rule needs neither Gherkin nor steps. The test calls the method directly:
@ParameterizedTest(name = "feeFor({0}) = {1}")@CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" })void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total));}[Theory][InlineData(4999, 490)][InlineData(5000, 0)][InlineData(1, 490)][InlineData(25000, 0)]public void FeeFor(int total, int fee){ Assert.Equal(fee, Delivery.FeeFor(total));}@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == feeconst cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}This is a parameterised test: one check and a table of cases. It looks like a scenario outline with "Examples", but it is for the programmer. A new case is a new row in the table.
The refusal is checked with a separate test. The rule throws an exception, and the test checks that it does:
@Test@DisplayName("an empty cart is refused")void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0));}[Fact(DisplayName = "an empty cart is refused")]public void EmptyCartIsRefused(){ Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0));}def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)test("an empty cart is refused", () => { assert.throws(() => deliveryFee(0));});More precisely, if you are curious
The tools are the standard ones for each language: JUnit 5 (parameterised tests live in the junit-jupiter-params module), xUnit with [Theory] and [InlineData], pytest, and node:test, built into Node. In the programming course the Python tests used unittest. pytest is the more widespread one: a test is an ordinary function with assert, and parametrize builds the table.
The idea
Which rule on which level
The rule is one. The checks are on two levels, and each has its own reader:
- A scenario is for the examples the business agreed on: exactly 50.00 €, 49.99 €, an empty cart. One or two examples per rule. It is the living documentation, and the business reads it.
- A component test is for the technical cases: 1 cent, a large amount, a negative amount, rounding. They protect the code, but they say nothing new about the rule.
Checking the boundary on both levels is not a waste. The scenario says what was agreed. The component test fails first, and exactly where the bug is.
The rule for choosing is short: would the business want to read this example? If yes, a scenario. If not, a component test.
Try it
Scenario or component test?
The idea
The run
The whole test file, run against the correct code. Everything passes:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("an empty cart is refused") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}PASS an empty cart is refusedPASS feeFor(1) = 490PASS feeFor(25000) = 0PASS feeFor(4999) = 490PASS feeFor(5000) = 0Tests: 5, passed: 5, failed: 0using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "an empty cart is refused")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}PASS FeeFor(total: 1, fee: 490)PASS FeeFor(total: 25000, fee: 0)PASS FeeFor(total: 4999, fee: 490)PASS FeeFor(total: 5000, fee: 0)PASS an empty cart is refusedTests: 5, passed: 5, failed: 0import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)PASS test_empty_cart_is_refusedPASS test_fee_for_total[1-490]PASS test_fee_for_total[25000-0]PASS test_fee_for_total[4999-490]PASS test_fee_for_total[5000-0]Tests: 5, passed: 5, failed: 0import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("an empty cart is refused", () => { assert.throws(() => deliveryFee(0));});PASS an empty cart is refusedPASS deliveryFee(1) = 490PASS deliveryFee(25000) = 0PASS deliveryFee(4999) = 490PASS deliveryFee(5000) = 0Tests: 5, passed: 5, failed: 0Now the programmer "simplifies" the comparison and rounds the amount to whole euros before it. 49.99 € becomes 50 €, and delivery becomes free:
package shop;
public class Delivery { public static final int FREE_FROM = 5000; public static final int FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int feeFor(int total) { if (total <= 0) { throw new IllegalArgumentException("An empty cart cannot be ordered"); } return Math.round(total / 100.0) * 100 >= FREE_FROM ? 0 : FEE; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// The delivery fee in cents. total is the cart total, also in cents. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("An empty cart cannot be ordered"); } return Math.Round(total / 100.0) * 100 >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """The delivery fee in cents. total is the cart total, also in cents.""" if total <= 0: raise ValueError("An empty cart cannot be ordered") return 0 if round(total / 100) * 100 >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// The delivery fee in cents. total is the cart total, also in cents.export function deliveryFee(total) { if (total <= 0) { throw new Error("An empty cart cannot be ordered"); } return Math.round(total / 100) * 100 >= FREE_FROM ? 0 : FEE;}The same tests, against the new code:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("an empty cart is refused") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}FAIL feeFor(4999) = 490: expected: <490> but was: <0>PASS an empty cart is refusedPASS feeFor(1) = 490PASS feeFor(25000) = 0PASS feeFor(5000) = 0Tests: 5, passed: 4, failed: 1using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "an empty cart is refused")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}FAIL FeeFor(total: 4999, fee: 490): Assert.Equal() Failure: Values differ Expected: 490 Actual: 0PASS FeeFor(total: 1, fee: 490)PASS FeeFor(total: 25000, fee: 0)PASS FeeFor(total: 5000, fee: 0)PASS an empty cart is refusedTests: 5, passed: 4, failed: 1import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)FAIL test_fee_for_total[4999-490]: assert 0 == 490PASS test_empty_cart_is_refusedPASS test_fee_for_total[1-490]PASS test_fee_for_total[25000-0]PASS test_fee_for_total[5000-0]Tests: 5, passed: 4, failed: 1import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("an empty cart is refused", () => { assert.throws(() => deliveryFee(0));});FAIL deliveryFee(4999) = 490: 0 !== 490PASS an empty cart is refusedPASS deliveryFee(1) = 490PASS deliveryFee(25000) = 0PASS deliveryFee(5000) = 0Tests: 5, passed: 4, failed: 1Exactly one case fails: 49.99 €. The name of the test tells you the amount, and the message tells you the expected and the actual value. You do not have to search for which step of which scenario.
More precisely, if you are curious
The output here is a short report, sorted by name: the tools really run, but inside a wrapper that prints only the line for each test and the count. The test names differ between languages because every tool builds them its own way: JUnit from the name of the parameterised test, xUnit from the method name and the arguments, pytest from the function name and the values, node:test from the text you give it.
The idea
Why not everything in Gherkin
Gherkin is for the conversation with the business: the examples agreed by the three amigos, written so that everyone can read them. As a testing tool it is expensive:
- every new line in a scenario needs a step, and steps have to be maintained;
- a scenario goes through more code, so it is slower;
- when it fails, the cause is further from the message.
And most importantly: fifteen technical scenarios hide the three that state the rule. Living documentation stays alive only while someone reads it.
So the scenarios are few and state the rule. The component tests are many and protect the code. This is the pyramid too: a wide base of fast tests, and above it fewer, slower ones that are closer to the customer.
More precisely, if you are curious
Aslak Hellesøy, who created Cucumber, has written more than once that Cucumber is first of all a tool for a shared language and collaboration, and that the tests are a by-product. If the scenarios are not written and read together with the business, what is left is an expensive way to write tests.
Exercise
The two levels
1. Which test protects what. The questions are about the scenarios from the previous lesson and the tests from this one.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Test levels · Testing in the life cycle · Why and what to automate · From scenario to steps
- Continues in: API and user interface · Tests in CI · Promo codes: from the request to the release
- Tasks on this topic: Automation