Promo codes: from the request to the release
In short: One request, "I want promo codes for the autumn", goes through everything in the course. The questions find the need and the people nobody asks. The story is split by its rules, the validity period goes through Example Mapping. The code rules become scenarios against a system with bugs, and since the change touches the total, delivery is protected from regression too. The caught bug becomes a report, two scenarios become code, and the matrix says what is touched when the requirement changes.
Problem
The request
The shop owner writes on a Monday morning:
"I want promo codes for the autumn. 10 % off, until the end of November. We release in two weeks."
Three sentences. They hold at least five rules nobody has stated yet, one open topic with money in it, and one person who will find out about the change only when they cannot do their job. In this project you go along the whole path: from the three sentences to the released and checked change.
The idea
The path
Every step comes from a module of the course:
- the questions: the need behind the request and the people who have to be asked;
- the story and the split: the request is big; it is cut by its rules;
- Example Mapping: the rules, the examples and the questions about the validity period of the code;
- the scenarios and the tests: the code rules become scenarios; the change also touches the total, and delivery depends on it, so it is protected from regression;
- the report: the bug the change brought in, described so that it gets fixed the first time;
- the automation: two scenarios for the promo code become code in the four languages;
- the matrix and the change: what the tests cover and what is touched when the requirement changes.
More precisely, if you are curious
In the browser, the scenarios run against a system with bugs. For the promo code it knows three of the rules: 10%, products on sale and which discount applies with loyalty. The validity period and "The code has already been used" are not in it: they are in the map, in the matrix and in the automation, where the scenarios run with the real tools. The regression, the table and the report are about delivery: the code change alters the total that delivery depends on, and delivery has to stay correct.
Try it
The questions
The idea
The answers
After talking to the owner, marketing and accounting, the request looks like this:
- the need: more new customers in the autumn. Metric: first orders from new customers per month, compared with last autumn; measured at the end of November;
- the rules: the code gives 10 % off; it is not valid on discounted products; it is used in one paid order ("The code has already been used"); it is valid until its last day inclusive ("The code has expired"); it does not add up with loyalty, the bigger one applies;
- accounting: wants to see which code gave each discount;
- open question: is the 50 € threshold for free delivery counted before the discount or after it. The owner decides; until then nobody else does.
Five rules in one story are a lot. The next step is the split.
Exercise
The story and the map
1. The split. The story with all the rules of the code is too big for one iteration.
Choose the pattern the story is cut by, then build the split in the order it is done.
As a customer I want to enter a promo code and get a discount so that I buy for less.
Step 2: Which pieces, in what order?
Add the pieces that form the split and put them in order: the first one is done first.
The pieces
The split
Pattern
- By business rules: The discount is one, but there are five rules: 10 %, not on discounted products, one paid order, a validity period, and which discount applies with loyalty. Each rule can be built, shown and checked separately.
The model split
- The code gives 10 % off regular products
- Discounted products get no promo code discount
- The code can be used in one paid order; after that "The code has already been used"
- The code is valid up to its last day; after that "The code has expired"
- A loyal customer with a code: the larger discount applies
Each rule is a story that can be shown on its own. 10 % is the base and comes first. "The bigger one applies" makes sense once there are two discounts, so it comes last. The field in the cart and the table in the database are layers: nobody can try just them.
2. The validity period. The first piece after 10 % is the validity period. The three amigos sat down with the cards.
Put each card in its place, then decide what the story is.
As a customer I want to know until when the code is valid so that I use it in time.
For every card press “Place as…” and choose: a rule (it becomes a column), an example for a rule, or a question. The arrow keys move between the choices, Esc closes. You can also use the mouse: drag the card to its place.
Rules
No rules yet. Place a card as a rule and it becomes a column.
Questions
- No questions yet.
Cards not placed yet
An expired code is refused with "The code has expired", and the total stays without the discount
An order on 30 November with AUTUMN10: a T-shirt for 20.00 € costs 18.00 €
The code was entered on 30 November at 23:50, and the order was paid on 1 December at 00:10. Is it valid?
The last day ends at midnight by whose clock: the shop's or the customer's?
The code is valid up to and including its last day
An order on 1 December with AUTUMN10: "The code has expired", the T-shirt is still 20.00 €
AUTUMN10 is valid until 30 November. An order on 15 November: a T-shirt for 20.00 € costs 18.00 €
The sorted board
- The code is valid up to and including its last day
- AUTUMN10 is valid until 30 November. An order on 15 November: a T-shirt for 20.00 € costs 18.00 €
- An order on 30 November with AUTUMN10: a T-shirt for 20.00 € costs 18.00 €
- An expired code is refused with "The code has expired", and the total stays without the discount
- An order on 1 December with AUTUMN10: "The code has expired", the T-shirt is still 20.00 €
Questions
- The last day ends at midnight by whose clock: the shop's or the customer's?
- The code was entered on 30 November at 23:50, and the order was paid on 1 December at 00:10. Is it valid?
What is the story? Ready for work. Both questions are about the same moment, midnight on the last day, and the owner settles them in one sentence. The rules and the examples are clear, the work can start, and the answer becomes one more example.
Both questions are about midnight on the last day. They look small, but the answer decides which customer sees "The code has expired" at 00:10. You will meet them again in the automation.
Exercise
The scenarios for the code
Three of the rules from the split run right here: 10%, products on sale and the loyal customer with a code. One scenario per example, in the owner's language.
Write the scenarios for the promo code so that all six bugs around the discounts are caught.
Solution
# language: en
Feature: Promo code
Scenario: The code gives 10% on a product at full price
Given a valid promo code
And a product at full price
And a customer without loyalty
When the customer checks out
Then the discount is 10 %
Scenario: The code does not apply to a product on sale
Given a valid promo code
And a product on sale
And a customer without loyalty
When the customer checks out
Then there is no discount
Scenario: A loyal customer with a code gets the larger discount
Given a loyal customer
And a valid promo code
And a product at full price
When the customer checks out
Then the discount is 10 %
Scenario: A loyal customer with a code for a product on sale keeps loyalty
Given a loyal customer
And a valid promo code
And a product on sale
When the customer checks out
Then the discount is 5 %
Scenario: A loyal customer without a code
Given a loyal customer
And no promo code
And a product at full price
When the customer checks out
Then the discount is 5 %
The trickiest bugs are with the loyal customer: whether the discounts add up, which one wins, and what happens to loyalty when the code does not apply to the product. And a loyal customer without a code is an example too.
The idea
The change touches delivery
The promo code changes the cart total. Delivery depends on the total: free from 50.00 € inclusive, otherwise 4.90 €. The programmer of the code will touch exactly the calculation of the total.
Whatever the owner decides about the threshold before or after the discount, the delivery rule from the total has to stay correct. That is why, before the change, delivery is protected from regression: scenarios for the agreed examples and tests for the technical cases, which fail if something around the boundary breaks.
Exercise
The regression of delivery
1. The scenarios. Few scenarios, one for each agreed example.
Write the delivery scenarios so that they catch the five bugs around the boundary and the empty cart.
Solution
Feature: Free delivery
Scenario: An order exactly on the boundary
Given a cart of 50.00 €
When I place the order
Then delivery is free
Scenario: An order just under the boundary
Given a cart of 49.99 €
When I place the order
Then delivery is 4.90 €
Scenario: An empty cart
Given an empty cart
When I place the order
Then the order is refused
The three examples are the ones from the Example Mapping for delivery: exactly 50.00 €, 49.99 € and an empty cart. 49.00 € is below the boundary, but far from it: if the total is rounded to whole euros, 49.00 € is still 49, and the bug gets through.
2. The technical cases. The table is for the component-level test: the cases the business does not want to read.
Write the cases that catch all six bugs, including the leftover of the old tariff for big orders.
The rule: Delivery is free for an order from 50 € inclusive, otherwise it is 4.90 €. An empty cart cannot be ordered.
| Input | Expected | The correct system | |||
|---|---|---|---|---|---|
| # | Cart total | Refused | Delivery | ||
An empty result cell is not checked. Amounts are written like 49.99.
Solution
| Cart total | Refused | Delivery |
|---|---|---|
| 0 | yes | |
| 49.99 | no | 4.90 |
| 50 | no | 0 |
| 150 | no | 0 |
The big order (150 €) is not in the scenarios, because the business has not agreed on it as an example. But the leftover of the old tariff ("from 100 € it is 4.90 € again") only shows above 100 €.
Exercise
The caught bug
1. The report. The programmer of the promo code calculates the total with fractional numbers and rounds it to whole euros. The scenario for 49.99 € fails.
Find a total where the bug shows, and write the report.
for amounts with cents just under 50 €
The rule: Delivery is free for an order from 50 € inclusive, otherwise it is 4.90 €. An empty cart cannot be ordered.
Steps to reproduce
- No steps yet. Add them from the vocabulary below.
The steps come from the vocabulary of the feature, as in the scenarios. The values go into the step itself: an amount like 50.00.
The model report
- Title
- Delivery is free for an order of 49.50 €
- Steps to reproduce
- Given a cart of 49.50 €
- When I place the order
- Expected
- delivery is 4.90 €
- Actual
- delivery is free
- Severity
- medium. The shop loses 4.90 € only on orders between 49.50 € and 49.99 €. The range is narrow, but the bug is real, and the customers who find it will use it.
- Priority
- high. The bug came in with the promo code change, which goes live with the campaign. The fix is small and has to be in the same release; otherwise the campaign will spread it.
The bug shows between 49.50 € and 49.99 €: there the rounding gives 50 €. At 49.00 € both systems charge 4.90 €, so such a report reproduces nothing. The priority is high, because the bug goes out with the campaign.
The idea
Automating two scenarios
The two promo code scenarios the owner read and accepted:
# language: enFeature: Promo code A promo code gives 10 % off up to and including its last day.
Scenario: The code gives 10 % off Given a cart of 20.00 € When I apply the code AUTUMN10 Then the total is 18.00 €
Scenario: An expired code Given a cart of 20.00 € And the code AUTUMN10 has expired When I apply the code AUTUMN10 Then I am told the code has expired And the total is 20.00 €The class that applies the code. The total is in cents, and the day is an input, not "today": this is the answer to the midnight question from the map and to the lesson about tests in CI:
package shop;
import java.time.LocalDate;
public class PromoCode { private final String code; private final LocalDate lastDay;
public PromoCode(String code, LocalDate lastDay) { this.code = code; this.lastDay = lastDay; }
// 10 % off the total in cents. An expired code is refused. public int apply(int total, LocalDate day) { if (day.isAfter(lastDay)) { throw new IllegalArgumentException("The code has expired"); } return total - total / 10; }}namespace Shop;
public class PromoCode(string code, DateOnly lastDay){ public string Code => code;
// 10 % off the total in cents. An expired code is refused. public int Apply(int total, DateOnly day) { if (day > lastDay) { throw new ArgumentException("The code has expired"); } return total - total / 10; }}class PromoCode: def __init__(self, code, last_day): self.code = code self.last_day = last_day
def apply(self, total, day): """10 % off the total in cents. An expired code is refused.""" if day > self.last_day: raise ValueError("The code has expired") return total - total // 10export class PromoCode { constructor(code, lastDay) { this.code = code; this.lastDay = lastDay; }
// 10 % off the total in cents. An expired code is refused. apply(total, day) { if (day > this.lastDay) { throw new Error("The code has expired"); } return total - Math.floor(total / 10); }}The "When" step applies the code, and the "Then" step checks the message:
@When("I apply the code {word}")public void apply(String code) { try { total = new PromoCode(code, LAST_DAY).apply(total, today); } catch (IllegalArgumentException e) { refusal = e.getMessage(); }}[When("I apply the code {word}")]public void Apply(string code){ try { total = new PromoCode(code, LastDay).Apply(total, today); } catch (ArgumentException e) { refusal = e.Message; }}@when("I apply the code {code}")def step_apply(context, code): try: context.total = PromoCode(code, LAST_DAY).apply(context.total, context.today) except ValueError as e: context.refusal = str(e)When("I apply the code {word}", function (code) { try { this.total = new PromoCode(code, LAST_DAY).apply(this.total, this.today); } catch (e) { this.refusal = e.message; }});@Then("I am told the code has expired")public void toldExpired() { assertEquals("The code has expired", refusal);}[Then("I am told the code has expired")]public void ToldExpired() => Assert.Equal("The code has expired", refusal);@then("I am told the code has expired")def step_told_expired(context): assert context.refusal == "The code has expired", f"message: {context.refusal}"Then("I am told the code has expired", function () { assert.equal(this.refusal, "The code has expired");});The whole steps file, run with the real tool. Both scenarios pass:
package shop;
import io.cucumber.java.ParameterType;import io.cucumber.java.en.Given;import io.cucumber.java.en.Then;import io.cucumber.java.en.When;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class PromoSteps { // The last day of the code in the scenarios. private static final LocalDate LAST_DAY = LocalDate.of(2026, 11, 30);
private int total; private LocalDate today = LocalDate.of(2026, 11, 15); private String refusal;
@ParameterType(name = "amount", value = "\\d+\\.\\d{2}") public int amount(String text) { return Integer.parseInt(text.replace(".", "")); }
@Given("a cart of {amount} €") public void cart(int cents) { total = cents; }
@Given("the code {word} has expired") public void expired(String code) { today = LAST_DAY.plusDays(1); }
@When("I apply the code {word}") public void apply(String code) { try { total = new PromoCode(code, LAST_DAY).apply(total, today); } catch (IllegalArgumentException e) { refusal = e.getMessage(); } }
@Then("the total is {amount} €") public void totalIs(int cents) { assertEquals(cents, total); }
@Then("I am told the code has expired") public void toldExpired() { assertEquals("The code has expired", refusal); }}PASS An expired codePASS The code gives 10 % offScenarios: 2, passed: 2, failed: 0using Reqnroll;using Xunit;
namespace Shop;
[Binding]public class PromoSteps{ // The last day of the code in the scenarios. private static readonly DateOnly LastDay = new(2026, 11, 30);
private int total; private DateOnly today = new(2026, 11, 15); private string refusal;
[StepArgumentTransformation(@"(\d+\.\d{2})", Name = "amount")] public int Amount(string text) => int.Parse(text.Replace(".", ""));
[Given("a cart of {amount} €")] public void Cart(int cents) => total = cents;
[Given("the code {word} has expired")] public void Expired(string code) => today = LastDay.AddDays(1);
[When("I apply the code {word}")] public void Apply(string code) { try { total = new PromoCode(code, LastDay).Apply(total, today); } catch (ArgumentException e) { refusal = e.Message; } }
[Then("the total is {amount} €")] public void TotalIs(int cents) => Assert.Equal(cents, total);
[Then("I am told the code has expired")] public void ToldExpired() => Assert.Equal("The code has expired", refusal);}PASS An expired codePASS The code gives 10 % offScenarios: 2, passed: 2, failed: 0from datetime import date, timedelta
from behave import given, register_type, then, when
from promo_code import PromoCode
# The last day of the code in the scenarios.LAST_DAY = date(2026, 11, 30)
def parse_amount(text): return int(text.replace(".", ""))
parse_amount.pattern = r"\d+\.\d{2}"register_type(Amount=parse_amount)
@given("a cart of {total:Amount} €")def step_cart(context, total): context.total = total
@given("the code {code} has expired")def step_expired(context, code): context.today = LAST_DAY + timedelta(days=1)
@when("I apply the code {code}")def step_apply(context, code): try: context.total = PromoCode(code, LAST_DAY).apply(context.total, context.today) except ValueError as e: context.refusal = str(e)
@then("the total is {total:Amount} €")def step_total(context, total): assert context.total == total, f"the total is {context.total}, expected {total}"
@then("I am told the code has expired")def step_told_expired(context): assert context.refusal == "The code has expired", f"message: {context.refusal}"PASS An expired codePASS The code gives 10 % offScenarios: 2, passed: 2, failed: 0import { Given, When, Then, defineParameterType } from "@cucumber/cucumber";import assert from "node:assert/strict";import { PromoCode } from "../../promo-code.js";
// The last day of the code in the scenarios.const LAST_DAY = "2026-11-30";
defineParameterType({ name: "amount", regexp: /\d+\.\d{2}/, transformer: (text) => Number(text.replace(".", "")),});
Given("a cart of {amount} €", function (cents) { this.total = cents;});
Given("the code {word} has expired", function (code) { this.today = "2026-12-01";});
When("I apply the code {word}", function (code) { try { this.total = new PromoCode(code, LAST_DAY).apply(this.total, this.today); } catch (e) { this.refusal = e.message; }});
Then("the total is {amount} €", function (cents) { assert.equal(this.total, cents);});
Then("I am told the code has expired", function () { assert.equal(this.refusal, "The code has expired");});PASS An expired codePASS The code gives 10 % offScenarios: 2, passed: 2, failed: 0And if the programmer forgets the validity period, the expired code still gives 10 % off, and the "An expired code" scenario fails:
package shop;
import java.time.LocalDate;
public class PromoCode { private final String code; private final LocalDate lastDay;
public PromoCode(String code, LocalDate lastDay) { this.code = code; this.lastDay = lastDay; }
// 10 % off the total in cents. An expired code is refused. public int apply(int total, LocalDate day) { return total - total / 10; }}FAIL An expired code: expected: <The code has expired> but was: <null>PASS The code gives 10 % offScenarios: 2, passed: 1, failed: 1namespace Shop;
public class PromoCode(string code, DateOnly lastDay){ public string Code => code;
// 10 % off the total in cents. An expired code is refused. public int Apply(int total, DateOnly day) { return total - total / 10; }}FAIL An expired code: Assert.Equal() Failure: Strings differ Expected: "The code has expired" Actual: nullPASS The code gives 10 % offScenarios: 2, passed: 1, failed: 1class PromoCode: def __init__(self, code, last_day): self.code = code self.last_day = last_day
def apply(self, total, day): """10 % off the total in cents. An expired code is refused.""" return total - total // 10FAIL An expired code: ASSERT FAILED: message: NonePASS The code gives 10 % offScenarios: 2, passed: 1, failed: 1export class PromoCode { constructor(code, lastDay) { this.code = code; this.lastDay = lastDay; }
// 10 % off the total in cents. An expired code is refused. apply(total, day) { return total - Math.floor(total / 10); }}FAIL An expired code: null !== 'The code has expired'PASS The code gives 10 % offScenarios: 2, passed: 1, failed: 1More precisely, if you are curious
These scenarios are not in the vocabulary of the course engine, so they are not checked against a system with a bug in the browser. They are run with Cucumber-JVM, Reqnroll, behave and cucumber-js, as in the "Automation" module, and their output is verified. The discount is rounded down to a whole cent (total / 10 in integers); for totals like 20.00 € that makes no difference, but it is a decision accounting has to confirm.
Exercise
The matrix and the change
1. Coverage and impact. The autumn is over. The metric showed that most codes were used by existing customers, and the owner changes the rule: the code is valid only for the customer's first order.
Mark the links. Then, in the second step, choose which tests have to be reviewed for the change.
Tests
- T-1: A new customer, a T-shirt for 20.00 € with AUTUMN10: 18.00 €.
- T-2: A new customer, discounted jeans for 40.00 € with AUTUMN10: 40.00 €.
- T-3: A new customer, a T-shirt for 20.00 € and discounted jeans for 40.00 € with AUTUMN10: 58.00 €.
- T-4: The customer has already paid an order with AUTUMN10. In a new order they enter AUTUMN10: "The code has already been used".
- T-5: A new customer enters AUTUMN10 on 1 December, after the last day: "The code has expired".
- T-6: A loyal customer with AUTUMN10, a T-shirt for 20.00 €: 18.00 €, not 17.00 €.
- T-7: A new customer enters AUTUMN10 on 30 November, the last day: a T-shirt for 20.00 € costs 18.00 €.
Press the cell where the test checks the requirement; press again to remove the link. The arrow keys move between the cells, Space or Enter marks.
| Requirements | T-1: A new customer, a T-shirt for 20.00 € with AUTUMN10: 18.00 €. | T-2: A new customer, discounted jeans for 40.00 € with AUTUMN10: 40.00 €. | T-3: A new customer, a T-shirt for 20.00 € and discounted jeans for 40.00 € with AUTUMN10: 58.00 €. | T-4: The customer has already paid an order with AUTUMN10. In a new order they enter AUTUMN10: "The code has already been used". | T-5: A new customer enters AUTUMN10 on 1 December, after the last day: "The code has expired". | T-6: A loyal customer with AUTUMN10, a T-shirt for 20.00 €: 18.00 €, not 17.00 €. | T-7: A new customer enters AUTUMN10 on 30 November, the last day: a T-shirt for 20.00 € costs 18.00 €. | Covered by |
|---|---|---|---|---|---|---|---|---|
| R-1 A promo code gives 10 % off. | 0 | |||||||
| R-2 A promo code does not apply to discounted products. | 0 | |||||||
| R-3 A code can be used in one paid order. A second try: "The code has already been used". | 0 | |||||||
| R-4 A code is valid up to and including its last day. After it: "The code has expired". | 0 | |||||||
| R-5 Discounts do not add up: the larger one applies. | 0 | |||||||
| R-6 The order shows which code gave the discount. | 0 | |||||||
| Checks | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
The reference matrix
- R-1: T-1, T-3
- R-2: T-2, T-3
- R-3: T-4
- R-4: T-5, T-7
- R-5: T-6
- R-6: uncovered
Which tests have to be reviewed? T-4, T-6. "T-4" has to be reviewed: the customer already has an order, the code is still refused, but with which message has to be decided. "T-6" is not linked to "R-3", but a loyal customer has past orders: the code no longer applies to them, and the T-shirt becomes 19.00 € by loyalty. "T-1", "T-2", "T-3", "T-5" and "T-7" are with a new customer and stay. One new test is needed too: a customer with a past order without a code enters AUTUMN10, and the code is refused.
"R-6" came from acceptance testing (accounting) and has no test yet. For the change, "T-4" is linked to the requirement, but "T-6" is not: the loyal customer, however, has past orders, and the code no longer applies to them. The links in the matrix are the start of the analysis, not the end.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Component tests · Tests in CI · Acceptance testing with the business · Measuring after the release
- Tasks on this topic: Project: promo codes