Apply Behavior-Driven Development to bridge business requirements and executable tests using Given-When-Then scenarios, and use BDD thinking to write better TDD tests.
Behavior-Driven Development (BDD) is TDD expressed in the language of business requirements. Where TDD asks 'does the code work correctly?', BDD asks 'does the software do what the business needs?'. BDD uses Given-When-Then scenarios — a structured format borrowed from acceptance criteria — to specify behavior in terms a product manager, QA engineer, and developer can all read and agree on.
You do not need a separate BDD framework (like Cucumber) to get the benefits of BDD. Jest's describe/it hierarchy, combined with Given-When-Then naming conventions, produces tests that serve as living documentation. This lesson shows how BDD thinking improves TDD practice by starting every session from a business scenario rather than a technical specification.
Given-When-Then (G-W-T) structures a scenario in three parts:
1User story:
2 As a student, I want to enrol in a free course so that I can start learning immediately.
3
4BDD scenarios:
5 Scenario 1: Successful free enrolment
6 Given I am logged in as a student with a verified email
7 And the course 'HTML Fundamentals' is published and free
8 When I click the 'Enrol for Free' button
9 Then I should see a 'Successfully enrolled' confirmation
10 And the course should appear in my 'My Courses' dashboard
11
12 Scenario 2: Already enrolled
13 Given I am already enrolled in 'HTML Fundamentals'
14 When I visit the course page
15 Then I should see a 'Continue Learning' button
16 And I should NOT see an 'Enrol for Free' button
17
18 Scenario 3: Unverified email
19 Given I am logged in but my email is not verified
20 When I try to enrol in any course
21 Then I should see a 'Please verify your email first' message
22 And I should NOT be enrolledThese scenarios can be read by a product manager, approved as correct, and then converted directly into Jest tests without ambiguity.
Map Given-When-Then directly to Jest's describe/it structure. No Cucumber or Gherkin required.
1// BDD scenario → Jest test (direct translation)
2
3// Scenario: Successful free enrolment
4describe('Course enrolment', () => {
5 describe('given a verified student and a published free course', () => {
6 let student: User;
7 let course: Course;
8
9 beforeEach(async () => {
10 // GIVEN
11 student = await createUser({ emailVerified: true, role: 'STUDENT' });
12 course = await createCourse({ published: true, price: 0 });
13 });
14
15 it('when the student enrols, then they see a success confirmation', async () => {
16 // WHEN
17 const result = await enrollmentService.enrol(student.id, course.id);
18
19 // THEN
20 expect(result.status).toBe('enrolled');
21 expect(result.confirmedAt).toBeDefined();
22 });
23
24 it('when the student enrols, then the course appears in their dashboard', async () => {
25 await enrollmentService.enrol(student.id, course.id);
26
27 const dashboard = await dashboardService.getStudentCourses(student.id);
28 expect(dashboard.map(c => c.id)).toContain(course.id);
29 });
30 });
31
32 describe('given a student who is already enrolled', () => {
33 let student: User;
34 let course: Course;
35
36 beforeEach(async () => {
37 student = await createUser({ emailVerified: true });
38 course = await createCourse({ published: true, price: 0 });
39 await enrollmentService.enrol(student.id, course.id); // pre-enrolled
40 });
41
42 it('when the student tries to enrol again, then it throws AlreadyEnrolledError', async () => {
43 await expect(
44 enrollmentService.enrol(student.id, course.id)
45 ).rejects.toThrow(AlreadyEnrolledError);
46 });
47 });
48
49 describe('given a student with an unverified email', () => {
50 it('when the student tries to enrol, then it throws EmailNotVerifiedError', async () => {
51 const unverified = await createUser({ emailVerified: false });
52 const course = await createCourse({ published: true, price: 0 });
53
54 await expect(
55 enrollmentService.enrol(unverified.id, course.id)
56 ).rejects.toThrow(EmailNotVerifiedError);
57 });
58 });
59});BDD scenarios serve as the test list before a TDD session begins. Writing scenarios first prevents missed edge cases.
1// Step 1: Write BDD scenarios from the user story (before any code)
2// User story: As an instructor, I want to set a coupon expiry date
3// so that promotions are time-limited
4
5// BDD Scenarios:
6// Scenario 1: Valid coupon within expiry date
7// Given a coupon with expiry date 2025-12-31
8// And today is 2025-06-01
9// When a student applies the coupon
10// Then it should apply successfully
11
12// Scenario 2: Expired coupon
13// Given a coupon with expiry date 2024-01-01
14// And today is 2025-06-01
15// When a student applies the coupon
16// Then they should see 'Coupon has expired'
17
18// Scenario 3: Coupon expires at midnight
19// Given a coupon with expiry date 2025-06-15
20// And today is 2025-06-15 at 23:59:59
21// When a student applies the coupon
22// Then it should apply (boundary condition: same day is valid)
23
24// Scenario 4: Coupon with no expiry date
25// Given a coupon with no expiry date set
26// When a student applies it at any time
27// Then it should always apply
28
29// Step 2: Convert scenarios to TDD tests
30describe('Coupon expiry', () => {
31 beforeEach(() => jest.useFakeTimers());
32 afterEach(() => jest.useRealTimers());
33
34 it('applies when coupon is within expiry date', () => {
35 jest.setSystemTime(new Date('2025-06-01'));
36 const coupon = { code: 'SAVE10', expiresAt: new Date('2025-12-31'), discount: 0.1 };
37 expect(() => applyCoupon(100, coupon)).not.toThrow();
38 });
39
40 it('throws ExpiredCouponError when coupon has passed its expiry date', () => {
41 jest.setSystemTime(new Date('2025-06-01'));
42 const coupon = { code: 'OLD10', expiresAt: new Date('2024-01-01'), discount: 0.1 };
43 expect(() => applyCoupon(100, coupon)).toThrow(ExpiredCouponError);
44 });
45
46 it('allows use on the same day as the expiry date (boundary)', () => {
47 jest.setSystemTime(new Date('2025-06-15T23:59:59Z'));
48 const coupon = { code: 'LAST10', expiresAt: new Date('2025-06-15T23:59:59Z'), discount: 0.1 };
49 expect(() => applyCoupon(100, coupon)).not.toThrow();
50 });
51
52 it('always applies when coupon has no expiry date', () => {
53 jest.setSystemTime(new Date('2099-01-01')); // far future
54 const coupon = { code: 'FOREVER', expiresAt: null, discount: 0.1 };
55 expect(() => applyCoupon(100, coupon)).not.toThrow();
56 });
57});
58// Notice: the boundary condition (same day) was discovered during BDD scenario writing
59// Without BDD, this edge case would likely have been missedBDD-style tests produce reports that read as business specifications, not code listings.
1# Running Jest with verbose reporter produces living documentation:
2npx jest --verbose
3
4# Output:
5# Course enrolment
6# given a verified student and a published free course
7# ✓ when the student enrols, then they see a success confirmation (45ms)
8# ✓ when the student enrols, then the course appears in their dashboard (38ms)
9# given a student who is already enrolled
10# ✓ when the student tries to enrol again, then it throws AlreadyEnrolledError (12ms)
11# given a student with an unverified email
12# ✓ when the student tries to enrol, then it throws EmailNotVerifiedError (11ms)
13#
14# This output is readable by a product manager
15# It is the executable specification of the enrolment feature1// jest-stare or jest-html-reporters produce HTML reports
2// that can be shared with non-technical stakeholders
3
4// jest.config.ts:
5reporters: [
6 'default',
7 ['jest-html-reporters', {
8 publicPath: './test-reports',
9 filename: 'report.html',
10 includeConsoleLog: false,
11 }],
12],
13
14// The HTML report shows:
15// - All test names (G-W-T format) in a readable hierarchy
16// - Pass/fail status
17// - Test duration
18// - Failure messages in human-readable format
19// Product managers can review this report before a release
20// to verify business scenarios are all passingMany teams adopt BDD thinking without adopting a separate BDD framework. Jest's native API is sufficient.
1// Pattern 1: describe/context/it hierarchy
2// (context is an alias for describe — some teams use it for readability)
3describe('Checkout', () => {
4 describe('context: cart has items', () => {
5 describe('context: valid payment method', () => {
6 it('completes the order and returns order id', async () => { ... });
7 it('sends confirmation email to the buyer', async () => { ... });
8 });
9
10 describe('context: card is declined', () => {
11 it('returns a payment failed error', async () => { ... });
12 it('does not create an order record', async () => { ... });
13 });
14 });
15
16 describe('context: cart is empty', () => {
17 it('throws CartEmptyError', async () => { ... });
18 });
19});
20
21// Pattern 2: Explicit G-W-T comments inside tests
22test('applies coupon discount to the checkout total', async () => {
23 // Given
24 const cart = new Cart();
25 cart.addItem({ id: 'p1', price: 100, qty: 1 });
26 const coupon = { code: 'SAVE20', discount: 0.2, expiresAt: null };
27
28 // When
29 const total = cart.applyAndCalculate(coupon);
30
31 // Then
32 expect(total).toBe(80);
33});
34
35// Pattern 3: Given-When-Then helper functions for readability
36const given = describe; // alias for readability
37const when = it; // alias
38
39given('a student enrolled in a course', () => {
40 beforeEach(() => { /* setup */ });
41
42 when('they complete the final lesson', async () => {
43 /* test body */
44 expect(certificate).toBeDefined();
45 });
46});A complete BDD→TDD session: write acceptance scenarios from a user story, convert to Jest tests, drive implementation through Red-Green-Refactor, producing living documentation.
1// USER STORY:
2// As a student, I want to apply a discount coupon at checkout
3// so that I can save money on course purchases.
4//
5// ACCEPTANCE CRITERIA (written by product + QA):
6// Scenario 1: Valid coupon reduces the total
7// Given a student has a cart with $100 of courses
8// And the student enters coupon code 'SAVE20'
9// When they apply the coupon
10// Then the cart total should show $80
11// And the discount should show -$20
12//
13// Scenario 2: Invalid coupon code
14// Given a student enters coupon code 'FAKECODE'
15// When they apply the coupon
16// Then they should see 'Invalid coupon code'
17// And the cart total should remain unchanged
18//
19// Scenario 3: Expired coupon
20// Given a coupon 'OLD10' expired on 2024-01-01
21// When a student tries to apply it after the expiry date
22// Then they should see 'Coupon has expired'
23//
24// Scenario 4: Coupon already used (single-use)
25// Given a single-use coupon 'ONCE50' that has been used once
26// When a student tries to apply it
27// Then they should see 'Coupon has already been used'
28
29// STEP 1: Convert acceptance criteria to Jest tests (BDD→TDD test list)
30import { CartService, CouponError } from './cart.service';
31
32const VALID_COUPONS = {
33 SAVE20: { code: 'SAVE20', discount: 0.2, expiresAt: new Date('2099-01-01'), maxUses: 100, useCount: 0 },
34 FAKECODE: undefined,
35 OLD10: { code: 'OLD10', discount: 0.1, expiresAt: new Date('2024-01-01'), maxUses: 100, useCount: 0 },
36 ONCE50: { code: 'ONCE50', discount: 0.5, expiresAt: new Date('2099-01-01'), maxUses: 1, useCount: 1 },
37};
38
39describe('Cart coupon application', () => {
40 let service: CartService;
41
42 beforeEach(() => {
43 service = new CartService(VALID_COUPONS as any);
44 });
45
46 // Scenario 1: Valid coupon
47 describe('given a cart with $100 of courses and a valid SAVE20 coupon', () => {
48 let cart: ReturnType<typeof service.createCart>;
49
50 beforeEach(() => {
51 cart = service.createCart();
52 cart.addItem({ id: 'c1', price: 100, qty: 1 });
53 });
54
55 it('when SAVE20 is applied, then the total is $80', () => {
56 service.applyCoupon(cart, 'SAVE20');
57 expect(cart.total()).toBe(80);
58 });
59
60 it('when SAVE20 is applied, then the discount shows -$20', () => {
61 service.applyCoupon(cart, 'SAVE20');
62 expect(cart.discount()).toBe(20);
63 });
64 });
65
66 // Scenario 2: Invalid coupon
67 describe('given a student enters an invalid coupon code', () => {
68 it('when FAKECODE is applied, then it throws InvalidCouponError', () => {
69 const cart = service.createCart();
70 expect(() => service.applyCoupon(cart, 'FAKECODE'))
71 .toThrow(CouponError.INVALID);
72 });
73
74 it('when FAKECODE is applied, then the cart total is unchanged', () => {
75 const cart = service.createCart();
76 cart.addItem({ id: 'c1', price: 100, qty: 1 });
77 try { service.applyCoupon(cart, 'FAKECODE'); } catch { /* expected */ }
78 expect(cart.total()).toBe(100);
79 });
80 });
81
82 // Scenario 3: Expired coupon
83 describe('given an expired coupon OLD10', () => {
84 it('when OLD10 is applied, then it throws ExpiredCouponError', () => {
85 const cart = service.createCart();
86 expect(() => service.applyCoupon(cart, 'OLD10'))
87 .toThrow(CouponError.EXPIRED);
88 });
89 });
90
91 // Scenario 4: Single-use coupon already used
92 describe('given a single-use coupon ONCE50 that is exhausted', () => {
93 it('when ONCE50 is applied, then it throws UsageLimitExceededError', () => {
94 const cart = service.createCart();
95 expect(() => service.applyCoupon(cart, 'ONCE50'))
96 .toThrow(CouponError.USAGE_LIMIT);
97 });
98 });
99});
100
101// IMPLEMENTATION (emerged from 8 TDD cycles):
102export class CouponError {
103 static readonly INVALID = 'Invalid coupon code';
104 static readonly EXPIRED = 'Coupon has expired';
105 static readonly USAGE_LIMIT = 'Coupon has already been used';
106}
107
108export class CartService {
109 constructor(private readonly coupons: Record<string, Coupon | undefined>) {}
110
111 createCart() {
112 const items: CartItem[] = [];
113 let discountRate = 0;
114 return {
115 addItem: (item: CartItem) => items.push(item),
116 total: () => items.reduce((s, i) => s + i.price * i.qty, 0) * (1 - discountRate),
117 discount: () => items.reduce((s, i) => s + i.price * i.qty, 0) * discountRate,
118 setDiscount: (rate: number) => { discountRate = rate; },
119 };
120 }
121
122 applyCoupon(cart: ReturnType<CartService['createCart']>, code: string): void {
123 const coupon = this.coupons[code];
124 if (!coupon) throw new Error(CouponError.INVALID);
125 if (new Date() > coupon.expiresAt) throw new Error(CouponError.EXPIRED);
126 if (coupon.useCount >= coupon.maxUses) throw new Error(CouponError.USAGE_LIMIT);
127 cart.setDiscount(coupon.discount);
128 }
129}The BDD scenarios were written from a user story before any code. Each scenario mapped to one or two Jest tests in a Given-When-Then named describe/it structure. The test names form the living documentation: running jest --verbose produces output that reads exactly like the acceptance criteria. Four scenarios became 8 tests, each covering one branch of the business logic. The implementation emerged from the tests — the CouponError constants, the maxUses check, and the expiresAt boundary were all discovered by failing tests, not pre-designed.
Congratulations on completing the Test-Driven Development module. You now have the full testing toolkit: unit testing with Jest, integration testing with Supertest and Prisma, end-to-end testing with Playwright, and TDD/BDD practices to drive quality from the start of every feature.
Your progress is saved in this browser, on this device only.