Skip to content
wannacode

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:

src/test/java/shop/DeliveryTest.java
@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));
}

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:

src/test/java/shop/DeliveryTest.java
@Test
@DisplayName("an empty cart is refused")
void emptyCartIsRefused() {
assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(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?

1. "A cart of exactly 50.00 €: delivery is free." The business agreed on it at the story meeting.
2. An amount of 1 cent, an amount of 250 € and an amount of 99,999.99 €.
3. A negative amount, say -5.00 €, which no customer can enter.

The idea

The run

The whole test file, run against the correct code. Everything passes:

src/test/java/shop/DeliveryTest.java
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));
}
}
Output
PASS an empty cart is refused
PASS feeFor(1) = 490
PASS feeFor(25000) = 0
PASS feeFor(4999) = 490
PASS feeFor(5000) = 0
Tests: 5, passed: 5, failed: 0

Now the programmer "simplifies" the comparison and rounds the amount to whole euros before it. 49.99 € becomes 50 €, and delivery becomes free:

src/main/java/shop/Delivery.java
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;
}
}

The same tests, against the new code:

src/test/java/shop/DeliveryTest.java
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));
}
}
Output
FAIL feeFor(4999) = 490: expected: <490> but was: <0>
PASS an empty cart is refused
PASS feeFor(1) = 490
PASS feeFor(25000) = 0
PASS feeFor(5000) = 0
Tests: 5, passed: 4, failed: 1

Exactly 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.

1. You remove the 250.00 € case (25000 cents) from the parameterised test. Which bug will no test in the lesson catch any more?
2. Which test catches the bug "an empty cart is not refused but pays 4.90 €"?
3. The programmer wants to add 49.50 € both to the scenarios and to the component test. What do you say?

Test

Check yourself

1. What is the difference between a scenario and a component test for one rule?
2. Why is a component test faster than a scenario?
3. What does a parameterised test do?
4. Why is not everything written in Gherkin?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·