In this lab, you'll build a shopping cart price calculator using Test-Driven Development (TDD). Rather than writing code first and tests second, you'll practice the Red-Green-Refactor cycle: write a failing test, write just enough code to pass it, then improve your code's design.
This domainβcalculating prices, discounts, and taxesβis where TDD truly shines. Getting edge cases wrong in pricing code costs real money, so businesses rely on comprehensive test coverage. By the end of this lab, you'll have a working price calculator with robust testsβand you'll experience how TDD shapes the way you think about code.
Time Estimate: 90-120 minutes
Prerequisites: Completion of Lab 1 (Vitest setup), Week 2 readings
Important
Windows Users: We recommend using PowerShell instead of Command Prompt for this lab. PowerShell supports most Unix-style commands. Where commands differ, we provide both versions.
By completing this lab, you will be able to:
- Apply the Red-Green-Refactor cycle to develop functions incrementally
- Distinguish between testing behavior vs. testing implementation details
- Write focused unit tests that verify expected outputs for given inputs
- Recognize when to use test doubles (and when real implementations suffice)
- Explain how TDD influences code design decisions
- Achieve high test coverage through test-first development
This lab directly applies concepts from your Week 2 readings:
- State verification vs. behavior verification: In this lab, we'll primarily use state verificationβchecking that functions return expected values. Fowler describes this as examining "the state of the SUT [System Under Test] and its collaborators after the method was exercised."
- Classical TDD approach: We'll follow the classical TDD style, using real implementations where possible and reserving test doubles for truly awkward dependencies.
- You'll see how simple utility functions rarely need test doublesβthey have no external dependencies to isolate.
- This reinforces Fowler's point that dummies, stubs, and mocks serve specific purposes; not every test needs them.
- We'll write tests that verify what functions do, not how they do it internally.
- If you refactor the implementation, your tests should still pass (that's the goal!).
After accepting the GitHub Classroom assignment, you'll have a personal repository. Clone it to your local machine:
git clone git@github.com:ClarkCollege-CSE-SoftwareEngineering/lab-2-test-driven-development-YOURUSERNAME.git
cd lab-2-test-driven-development-YOURUSERNAMENote
Replace YOURUSERNAME with your actual GitHub username. You can copy the exact clone URL from your repository page on GitHub.
Your cloned repository already contains:
README.md-- These lab instructions.gitignore-- Pre-configured to ignorenode_modules/,dist/,coverage/, etc..github/workflows/test.yml-- GitHub Actions workflow for automated testing
Warning
The npm-init command below must be run within the root directory of your project.
npm init -yThis creates a package.json file. Open it and update it to enable ES modules:
{
"name": "lab-2-test-driven-development-YOURUSERNAME",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1"
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
- "type": "commonjs"
+ "type": "module"
}Install TypeScript, Vitest, and related tooling:
npm install -D typescript vitest @vitest/coverage-v8Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}Create a vitest.config.ts file in your project root:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "vitest.config.ts"],
thresholds: {
statements: 90,
branches: 90,
functions: 90,
lines: 90,
},
},
},
});# Linux/macOS/PowerShell:
mkdir -p src/__tests__
# Windows Command Prompt:
mkdir src\__tests__β
Checkpoint: Run npm test β it should complete (with no tests found yet).
Your project structure should look like:
lab-2-test-driven-development-YOURUSERNAME/
βββ .github/
β βββ workflows/
β βββ test.yml β (provided in template)
βββ .gitignore β (provided in template)
βββ node_modules/
βββ src/
β βββ __tests__/
βββ package.json β (you created this)
βββ README.md β (provided in template)
βββ tsconfig.json β (you created this)
βββ vitest.config.ts β (you created this)
Now we'll practice the Red-Green-Refactor cycle. Remember: write the test first, watch it fail, then write the minimum code to pass.
Create src/__tests__/cartUtils.test.ts:
import { describe, it, expect } from "vitest";
import { applyDiscount } from "../cartUtils";
describe("applyDiscount", () => {
it("applies a percentage discount to a price", () => {
expect(applyDiscount(100, 10)).toBe(90);
});
});Run the test:
npm testβ Checkpoint: You should see a red failing test. The error will say something like "Cannot find module '../cartUtils'". This is expected! We haven't written the implementation yet.
π€ Reflection Question: Why do we intentionally write a failing test first? How does this relate to what Fowler describes as "state verification"?
Create src/cartUtils.ts with the absolute minimum to pass:
export function applyDiscount(price: number, discountPercent: number): number {
return price - (price * discountPercent) / 100;
}Run the test:
npm testβ Checkpoint: Your test should now pass (green). We wrote just enough codeβnothing more.
Now let's handle edge cases. Add these tests one at a time, running tests after each addition:
describe("applyDiscount", () => {
it("applies a percentage discount to a price", () => {
expect(applyDiscount(100, 10)).toBe(90);
});
it("returns the original price when discount is 0%", () => {
expect(applyDiscount(50, 0)).toBe(50);
});
it("returns 0 when discount is 100%", () => {
expect(applyDiscount(75, 100)).toBe(0);
});
it("handles decimal prices correctly", () => {
expect(applyDiscount(19.99, 10)).toBeCloseTo(17.99, 2);
});
it("throws an error for negative prices", () => {
expect(() => applyDiscount(-10, 10)).toThrow("Price cannot be negative");
});
it("throws an error for negative discount percentages", () => {
expect(() => applyDiscount(100, -5)).toThrow("Discount cannot be negative");
});
it("throws an error for discount greater than 100%", () => {
expect(() => applyDiscount(100, 150)).toThrow(
"Discount cannot exceed 100%"
);
});
});Some tests will fail! Update your implementation to make them pass:
export function applyDiscount(price: number, discountPercent: number): number {
if (price < 0) {
throw new Error("Price cannot be negative");
}
if (discountPercent < 0) {
throw new Error("Discount cannot be negative");
}
if (discountPercent > 100) {
throw new Error("Discount cannot exceed 100%");
}
return price - (price * discountPercent) / 100;
}Run tests after each change to ensure all pass.
Our current implementation works. Let's consider if we can make it clearer:
export function applyDiscount(price: number, discountPercent: number): number {
if (price < 0) {
throw new Error("Price cannot be negative");
}
if (discountPercent < 0) {
throw new Error("Discount cannot be negative");
}
if (discountPercent > 100) {
throw new Error("Discount cannot exceed 100%");
}
const discountMultiplier = 1 - discountPercent / 100;
return price * discountMultiplier;
}Run tests again to ensure nothing broke. Both implementations produce the same resultsβthe refactored version just expresses the math differently.
π€ Reflection Question: In the mockist vs. classicist debate from Fowler's article, which approach are we using here? Why don't we need any test doubles for this function?
Let's build a function that calculates sales tax, with support for tax-exempt items.
Add a new describe block in src/__tests__/cartUtils.test.ts:
import { applyDiscount, calculateTax } from "../cartUtils";
// ... existing applyDiscount tests ...
describe("calculateTax", () => {
it("calculates tax on a price", () => {
expect(calculateTax(100, 8.5)).toBeCloseTo(8.5, 2);
});
it("returns 0 tax when rate is 0%", () => {
expect(calculateTax(50, 0)).toBe(0);
});
it("handles decimal prices correctly", () => {
expect(calculateTax(19.99, 10)).toBeCloseTo(2.0, 2);
});
it("returns 0 tax when item is tax-exempt", () => {
expect(calculateTax(100, 8.5, true)).toBe(0);
});
it("throws an error for negative prices", () => {
expect(() => calculateTax(-10, 8.5)).toThrow("Price cannot be negative");
});
it("throws an error for negative tax rates", () => {
expect(() => calculateTax(100, -5)).toThrow("Tax rate cannot be negative");
});
});Run the tests:
npm testβ
Checkpoint: All calculateTax tests should fail (red) because the function doesn't exist.
Add to src/cartUtils.ts:
export function calculateTax(
price: number,
taxRate: number,
isTaxExempt: boolean = false
): number {
if (price < 0) {
throw new Error("Price cannot be negative");
}
if (taxRate < 0) {
throw new Error("Tax rate cannot be negative");
}
if (isTaxExempt) {
return 0;
}
return price * (taxRate / 100);
}Run the tests:
npm testβ Checkpoint: All tests should pass (green).
The current implementation is clean. One small improvementβwe could round to 2 decimal places since we're dealing with currency:
export function calculateTax(
price: number,
taxRate: number,
isTaxExempt: boolean = false
): number {
if (price < 0) {
throw new Error("Price cannot be negative");
}
if (taxRate < 0) {
throw new Error("Tax rate cannot be negative");
}
if (isTaxExempt) {
return 0;
}
const tax = price * (taxRate / 100);
return Math.round(tax * 100) / 100;
}Run tests againβthey should still pass since we're using toBeCloseTo for decimal comparisons.
π€ Reflection Question: Notice that we changed the implementation (added rounding), but our tests still pass because we used toBeCloseTo. This is what Kent C. Dodds means by "not testing implementation details." What would a test that does test implementation details look like?
Now it's your turn! Implement a calculateTotal function using TDD. This function calculates the final price for a shopping cart, incorporating discounts and taxes.
The function should:
- Accept an array of cart items, each with
price,quantity, and optionallyisTaxExempt - Accept a
discountPercent(applied to subtotal before tax) - Accept a
taxRate(applied after discount, only to non-exempt items) - Return an object with
subtotal,discount,tax, andtotal
Add this type to your src/cartUtils.ts:
export interface CartItem {
price: number;
quantity: number;
isTaxExempt?: boolean;
}
export interface CartTotals {
subtotal: number;
discount: number;
tax: number;
total: number;
}TODO: Write at least 6 test cases FIRST, then implement the function.
Start by adding this skeleton to your test file:
import {
applyDiscount,
calculateTax,
calculateTotal,
CartItem,
} from "../cartUtils";
// ... existing tests ...
describe("calculateTotal", () => {
// TODO: Add at least 6 test cases
// Consider: single item, multiple items, discounts, tax-exempt items,
// empty cart, mixed tax-exempt and taxable items
it("calculates totals for a single item", () => {
// TODO: Write this test
});
it("calculates totals for multiple items", () => {
// TODO: Write this test
});
it("applies discount before calculating tax", () => {
// TODO: Write this test
});
it("excludes tax-exempt items from tax calculation", () => {
// TODO: Write this test
});
// TODO: Add at least 2 more test cases
});Then implement calculateTotal in src/cartUtils.ts:
export function calculateTotal(
items: CartItem[],
discountPercent: number = 0,
taxRate: number = 0
): CartTotals {
// TODO: Implement this function using TDD
// Remember: write each test first, see it fail, then make it pass
throw new Error("Not implemented");
}Hints:
- Calculate subtotal first (sum of price Γ quantity for all items)
- Apply discount to get discounted subtotal
- Calculate tax only on non-exempt items (after discount is applied proportionally)
- Return all four values rounded to 2 decimal places
β
Checkpoint: When complete, run npm run test:coverage. All tests should pass, and you should have at least 90% coverage.
npm run test:coverageβ Checkpoint: Coverage should be at least 90% across all metrics. If not, identify untested code paths and add tests.
Create or update the README.md in your project root with:
- Project description (1-2 sentences)
- How to run tests (commands)
- Functions implemented (brief description of each)
- Reflection section answering:
- How did TDD change the way you approached implementing
calculateTotal? - Which of Fowler's test double types (dummy, stub, fake, spy, mock) did you need for this lab? Why or why not?
- What's one thing that would have been different if you wrote the implementation first?
- How did TDD change the way you approached implementing
Your repository should contain:
tdd-cart-calculator/
βββ .github/
β βββ workflows/
β βββ test.yml # GitHub Actions (provided)
βββ src/
β βββ __tests__/
β β βββ cartUtils.test.ts # At least 19 tests
β βββ cartUtils.ts # applyDiscount, calculateTax, calculateTotal
βββ .gitignore
βββ package.json
βββ README.md # With reflection section
βββ tsconfig.json
βββ vitest.config.ts
- All three functions implemented:
applyDiscount,calculateTax,calculateTotal - At least 19 total test cases (7 + 6 + 6 minimum)
- 90%+ test coverage
- README with reflection section
- All tests passing
- TypeScript compiles without errors
| Criterion | Points | Description |
|---|---|---|
| Project Setup | 15 | TypeScript, Vitest, and scripts configured correctly |
| Guided Functions | 20 | applyDiscount and calculateTax implemented with all provided tests passing |
Independent TDD (calculateTotal) |
20 | At least 6 well-designed tests; function fully implemented |
| Test Coverage | 15 | 90%+ coverage across all metrics |
| README & Reflection | 20 | Clear documentation; thoughtful answers connecting to readings |
| Code Quality | 10 | Clean code, meaningful names, proper TypeScript types |
| Total | 100 |
Note
Any code added towards these goals will not be evaluated for grading purposes.
Finished early? Try these extensions:
- Add
applyPromoCode()function β Support different promo types: percentage off, fixed amount off, buy-one-get-one - Add
calculateShipping()function β Free shipping over a threshold, flat rate, or weight-based - Add quantity discounts β "Buy 3+ get 10% off" logic
- Use parameterized tests β Use Vitest's
it.each()to test multiple discount/tax scenarios concisely
Make sure your import path matches your file structure. The import should be:
import { applyDiscount } from "../cartUtils"; // Note: no .ts extensionEnsure globals: true is set in vitest.config.ts, or explicitly import:
import { describe, it, expect } from "vitest";Use toBeCloseTo for decimal comparisons:
expect(result).toBeCloseTo(17.99, 2); // 2 decimal placesCheck the HTML coverage report in coverage/index.html to see which lines aren't covered. Common misses:
- Error-throwing branches
- Edge cases like empty arrays
- Optional parameters with default values
- Check that all dependencies are in
package.json(not just installed locally) - Ensure
package.jsonhas"type": "module" - Verify the test script works with
npm test(not justvitest)
- Push your completed code to your GitHub repository
- Verify that GitHub Actions tests pass (green checkmark)
- Submit your repository URL via Canvas
Due: Tuesday, January 20, 2026 at 11:59 PM
Note
January 19 is Martin Luther King Jr. Day. Per the Clark College Academic Calendar, this is a campus holiday with no classes being held and campus closed. Therefore, the due date for this assignment has been shifted to the next day to accommdate.
- π Vitest Documentation
- π Mocks Aren't Stubs β Martin Fowler
- π Test Double β Martin Fowler
- π Testing Implementation Details β Kent C. Dodds