Skip to main content

API & Integration Tests

Exercise C
⏱ 25 min🟡 Intermediate

In this exercise you will use Copilot to generate Supertest coverage for the full checkout pipeline — Cart → Discount → Fraud → Payment → Notification. Copilot is effective at scaffolding route contracts, but it cannot know your specific status codes or error shapes without context. Attaching domain-rules.md is what turns generic scaffolding into domain-aware assertions.

Practice repo for this stepExercise C

Work from master in copilot-testing-workshop. Use the solution checkpoints only if you need to catch up or compare your result after the exercise.

Participant goalWrite Supertest coverage for the full checkout pipeline. Use domain-rules.md as Copilot context to generate domain-aware assertions for every error code.
Run commandnpm run test:api

Primary files

  • workshop-exercises/tests/api/checkout.test.ts
  • workshop-exercises/.copilot/context/domain-rules.md
  • workshop-exercises/src/openapi.ts

Recovery checkpoint

  • solutions
🧠Knowledge Check

For your default API tests, where do you get the fastest, most reliable feedback?

Pick a strategy, reveal rationale, then align your first implementation slice with the recommendation.

Checkout Pipeline Under Test
🧪Test File
uses▸
📡Supertest
HTTP request▸
🔌Express App
▸
⚙️Cart / Discount / Fraud
▸
🗄️In-Memory Store

The Checkout Pipeline​

Every exercise in this workshop runs against the same sample checkout system. The API tests in this step cover the full pipeline:

ServiceKey RoutesWhat to test
CartPOST /api/cart/:userId/itemsAdd item, merge duplicate products, reject invalid price/quantity
DiscountPOST /api/discount/applyValid codes, expired codes, unknown codes, minimum-order gate
FraudPOST /api/fraud/checkLow-risk approval, high-risk block, blacklisted country
PaymentPOST /api/payment/captureSuccessful capture, fraud-blocked order, invalid state transition

Step 1 — Generate the API Test Scaffold​

Attach the domain-rules file so Copilot generates correct error codes and boundary values:

Fill in the it.todo placeholders in tests/api/checkout.test.ts using Supertest.

Context files:
- #file:tests/api/checkout.test.ts
- #file:.copilot/context/domain-rules.md
- #file:src/openapi.ts

Cover, in order:
- Cart: empty cart for a new user, add item, remove item
- Discount: validate a valid promo code, apply SAVE10 and check final total, reject an
expired code with 400
- Fraud: approve a low-risk order, reject a high-risk order
- Payment: create, capture, and refund a payment intent
- Notification: send a receipt notification and retrieve logs
- Full flow: add to cart → apply discount → check fraud → charge → capture → send receipt,
end-to-end

Assert both the HTTP status code AND the response body shape for every test.
Use the exact error codes from domain-rules.md for error cases (e.g. INVALID_DISCOUNT_CODE),
not generic messages. Use the existing beforeEach (resetWorkshopData + login) already in the file.

Step 2 — Review the Generated Scaffold​

HintFull generated scaffold
// tests/api/checkout.test.ts (Copilot-generated — review required)
import request from 'supertest';
import { app, resetWorkshopData } from '../../src/app';

const userId = 'user-workshop-01';
let token: string;

beforeEach(async () => {
await resetWorkshopData();
// Obtain a seeded auth token for protected routes
const login = await request(app)
.post('/api/auth/login')
.send({ email: 'alice@example.com', password: 'workshop-password' });
token = login.body.data.token;
});

describe('Cart', () => {
it('adds an item and returns the updated subtotal', async () => {
const res = await request(app)
.post(`/api/cart/${userId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ productId: 'prod-1', price: 25, quantity: 1 });

expect(res.status).toBe(201);
expect(res.body.data.subtotal).toBe(25);
});

it('merges quantities when the same product is added twice', async () => {
await request(app)
.post(`/api/cart/${userId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ productId: 'prod-1', price: 25, quantity: 1 });

const res = await request(app)
.post(`/api/cart/${userId}/items`)
.set('Authorization', `Bearer ${token}`)
.send({ productId: 'prod-1', price: 25, quantity: 2 });

expect(res.status).toBe(201);
// quantity should be merged to 3, not two separate line items
const items = res.body.data.items;
expect(items).toHaveLength(1);
expect(items[0].quantity).toBe(3);
});
});

describe('Discount', () => {
it('applies SAVE10 and returns the correct discounted total', async () => {
const res = await request(app)
.post('/api/discount/apply')
.set('Authorization', `Bearer ${token}`)
.send({ code: 'SAVE10', subtotal: 100 });

expect(res.status).toBe(200);
expect(res.body.data.discountAmount).toBe(10);
expect(res.body.data.finalTotal).toBe(90);
});

it('rejects an unknown code with INVALID_DISCOUNT_CODE', async () => {
const res = await request(app)
.post('/api/discount/apply')
.set('Authorization', `Bearer ${token}`)
.send({ code: 'BOGUS', subtotal: 100 });

expect(res.status).toBe(404);
expect(res.body.error.code).toBe('INVALID_DISCOUNT_CODE');
});
});

describe('Fraud', () => {
it('blocks a high-risk order (amount > $1000, country XX)', async () => {
const res = await request(app)
.post('/api/fraud/check')
.set('Authorization', `Bearer ${token}`)
.send({ orderAmount: 10000, itemCount: 50, ipCountry: 'XX' });

expect(res.status).toBe(200);
expect(res.body.data.approved).toBe(false);
expect(res.body.data.riskLevel).toBe('high');
});

it('approves a low-risk order', async () => {
const res = await request(app)
.post('/api/fraud/check')
.set('Authorization', `Bearer ${token}`)
.send({ orderAmount: 50, itemCount: 2, ipCountry: 'DE' });

expect(res.status).toBe(200);
expect(res.body.data.approved).toBe(true);
expect(res.body.data.riskLevel).toBe('low');
});
});
🔍 Review checklist for API tests:

Does every test assert both status code and response body?

  • Do error cases use the exact error codes from domain-rules.md (e.g. INVALID_DISCOUNT_CODE, not NOT_FOUND)?
  • Does the fraud test verify riskLevel and approved — not just one field?
  • Is the cart merge test asserting items length, not just status?

Step 3 — Run the API Tests​

npm run test:api
Expected output
PASS tests/api/checkout.test.ts
Cart
✓ adds an item and returns the updated subtotal (28 ms)
✓ merges quantities when the same product is added twice (22 ms)
Discount
✓ applies SAVE10 and returns the correct discounted total (18 ms)
✓ rejects an unknown code with INVALID_DISCOUNT_CODE (12 ms)
Fraud
✓ blocks a high-risk order (amount > $1000, country XX) (15 ms)
✓ approves a low-risk order (9 ms)

Tests: 6 passed, 6 total

Part B — Integration Tests with Real State​

Unlike the Supertest coverage above (HTTP layer) or Exercise A/B's unit tests (one function in isolation), tests/integration/checkout.pipeline.test.ts exercises real cross-module wiring by calling the exported service singletons directly — cartService → discountService → fraudService → paymentService → notificationService — with no HTTP layer and no mocks. It ships with 6 it.todo placeholders; fill them in with Copilot.

Fill in the it.todo placeholders in tests/integration/checkout.pipeline.test.ts.

Context files:
- #file:tests/integration/checkout.pipeline.test.ts
- #file:src/app.ts
- #file:.copilot/context/domain-rules.md

The services are already imported and exported as singletons from src/app.ts:
cartService, discountService, fraudService, paymentService, notificationService, userService.
Note: cartService and userService methods are async; discountService, fraudService, and
paymentService methods are synchronous (no await, no Promise).

Cover, in order:
1. adds an item via cartService.addItem and reflects it in cartService.getCart's subtotal
2. applies a discount via discountService.apply on top of the cart subtotal
3. approves a low-risk order via fraudService.check for the same cart
4. charges, captures, and refunds a payment via paymentService
5. sends and retrieves a receipt via notificationService.send
6. runs the full pipeline end-to-end and asserts the final order state
Solution reference

A fully filled-in version of this file is on the solutions branch.

// tests/integration/checkout.pipeline.test.ts (Copilot-generated — review required)
import { resetWorkshopData, cartService, discountService, fraudService, paymentService, notificationService, userService } from '../../src/app';

describe('checkout pipeline (integration)', () => {
let userId: string;

beforeEach(async () => {
await resetWorkshopData();
const { user } = await userService.authenticate('alice@example.com', 'workshop-password');
userId = user.id;
});

it('adds an item via cartService and reflects it in the cart total', async () => {
const cart = await cartService.addItem(userId, {
productId: 'prod_1', name: 'Workshop T-Shirt', price: 25, quantity: 2,
});

expect(cart.items).toHaveLength(1);
expect(cart.subtotal).toBe(50);
});

it('applies a discount via discountService on top of the cart subtotal', async () => {
const cart = await cartService.addItem(userId, {
productId: 'prod_1', name: 'Workshop T-Shirt', price: 100, quantity: 1,
});

const discounted = discountService.apply('SAVE10', cart.subtotal);

expect(discounted.discountAmount).toBe(10);
expect(discounted.finalTotal).toBe(90);
});

it('approves a low-risk order via fraudService for the same cart', () => {
const result = fraudService.check({ userId, orderAmount: 50, itemCount: 2, ipCountry: 'DE' });

expect(result.approved).toBe(true);
expect(result.riskLevel).toBe('low');
});

it('charges, captures, and refunds a payment via paymentService', () => {
const intent = paymentService.charge({ userId, amount: 45, currency: 'usd' });
expect(intent.status).toBe('pending');

const captured = paymentService.capture(intent.id);
expect(captured.status).toBe('captured');

const refunded = paymentService.refund(intent.id);
expect(refunded.status).toBe('refunded');
});

it('sends and retrieves a receipt via notificationService after payment', () => {
const log = notificationService.send({
userId, type: 'receipt', email: 'alice@example.com', subject: 'Order confirmed', body: 'Thanks for your order.',
});

expect(log.type).toBe('receipt');
expect(log.id).toBeTruthy();
});

it('runs the full pipeline end-to-end and asserts the final order state', async () => {
const cart = await cartService.addItem(userId, {
productId: 'prod_1', name: 'Workshop T-Shirt', price: 100, quantity: 1,
});
const discounted = discountService.apply('SAVE10', cart.subtotal);
const fraud = fraudService.check({ userId, orderAmount: discounted.finalTotal, itemCount: 1, ipCountry: 'DE' });
expect(fraud.approved).toBe(true);

const intent = paymentService.charge({ userId, amount: discounted.finalTotal, currency: 'usd' });
const captured = paymentService.capture(intent.id);
expect(captured.status).toBe('captured');

const log = notificationService.send({
userId, type: 'receipt', email: 'alice@example.com', subject: 'Order confirmed', body: 'Thanks for your order.',
});
expect(log.type).toBe('receipt');
});
});

Part C — Postman Collection for the Checkout Pipeline​

Supertest and Postman check the same contract from different angles: Supertest runs in-process against the Express app, while a Postman collection drives real HTTP requests and can be shared with anyone on the team — or a non-engineer — who just wants to poke at the API. There's no Newman or Postman tooling wired into this repo yet, so treat this as a standalone deliverable: a collection.json you run with npx newman run collection.json or import into the Postman app, not something added to npm run test:api.

Run the prompt below from inside workshop-exercises/ so the #file: references resolve. tests/api/checkout.test.ts covers the same three routes, the same domain rules, and the same auth-token pattern — once Copilot generates the collection, open it side-by-side with that file and check that the assertions actually agree.

#file:.copilot/context/domain-rules.md
#file:src/openapi.ts

Generate a Postman collection (collection.json) for the checkout pipeline.

Cover these requests, each with a pm.test() assertion block:
- POST /api/cart/:userId/items — add item, assert 201 + response.data.subtotal
- POST /api/discount/apply — valid SAVE10 code, unknown code (BOGUS → INVALID_DISCOUNT_CODE)
- POST /api/fraud/check — high-risk order (amount > $1000, country XX) → approved: false

Store the auth token from the login request into a collection variable ({{token}})
in a test script, and reuse it in the Authorization header of every later request.
Assert both pm.response.code AND the parsed response body — never one without the other.
HintExample generated collection.json (trimmed)
{
"info": { "name": "Checkout Pipeline", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" },
"variable": [{ "key": "baseUrl", "value": "http://localhost:4000" }, { "key": "token", "value": "" }],
"item": [
{
"name": "Login",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/auth/login",
"body": { "mode": "raw", "raw": "{\"email\":\"alice@example.com\",\"password\":\"workshop-password\"}" }
},
"event": [{
"listen": "test",
"script": { "exec": [
"const body = pm.response.json();",
"pm.test('login succeeds', () => pm.response.to.have.status(200));",
"pm.collectionVariables.set('token', body.data.token);"
] }
}]
},
{
"name": "Add cart item",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/cart/:userId/items",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"body": { "mode": "raw", "raw": "{\"productId\":\"prod_1\",\"name\":\"Workshop T-Shirt\",\"price\":25,\"quantity\":2}" }
},
"event": [{
"listen": "test",
"script": { "exec": [
"const body = pm.response.json();",
"pm.test('status is 201', () => pm.response.to.have.status(201));",
"pm.test('subtotal reflects the added item', () => pm.expect(body.data.subtotal).to.eql(50));"
] }
}]
},
{
"name": "Apply discount - unknown code",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/discount/apply",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"body": { "mode": "raw", "raw": "{\"code\":\"BOGUS\",\"subtotal\":100}" }
},
"event": [{
"listen": "test",
"script": { "exec": [
"const body = pm.response.json();",
"pm.test('status is 404', () => pm.response.to.have.status(404));",
"pm.test('error code is INVALID_DISCOUNT_CODE', () => pm.expect(body.error.code).to.eql('INVALID_DISCOUNT_CODE'));"
] }
}]
},
{
"name": "Fraud check - high risk",
"request": {
"method": "POST",
"url": "{{baseUrl}}/api/fraud/check",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"body": { "mode": "raw", "raw": "{\"userId\":\"{{userId}}\",\"orderAmount\":10000,\"itemCount\":2,\"ipCountry\":\"XX\"}" }
},
"event": [{
"listen": "test",
"script": { "exec": [
"const body = pm.response.json();",
"pm.test('status is 200', () => pm.response.to.have.status(200));",
"pm.test('order is not approved', () => pm.expect(body.data.approved).to.eql(false));"
] }
}]
}
]
}
🔍 Review checklist for the generated collection:

Does the login request's test script store the token with pm.collectionVariables.set('token', ...), and does every later request read {{token}} in its Authorization header?

  • Does every pm.test() block assert both pm.response.code and a parsed field from the body — not one alone?
  • Does the unknown-discount-code test check error.code === 'INVALID_DISCOUNT_CODE' from domain-rules.md, not just a 4xx status?
  • Does the fraud test assert approved: false for the high-risk case, matching tests/api/checkout.test.ts?
  • Would npx newman run collection.json actually pass against the running dev server?
🏋️Hands-on Challenge
⏱10 min

Add coverage for the Payment lifecycle. Use Copilot to generate tests for a successful capture and an invalid state transition. Refer to the Payment lifecycle in domain-rules.md.

HintPayment lifecycle rules
  • Valid transitions: pending → captured (POST /api/payment/:id/capture)
  • Invalid transition: refunding a pending intent returns INVALID_PAYMENT_STATE with HTTP 400
  • Assert the error.code in the error response — not just the status
SolutionFull Solution
describe('Payment', () => {
it('captures a pending payment intent', async () => {
// First create a payment intent via checkout
const { paymentId } = await setupPendingPayment(userId, token);

const res = await request(app)
.post(`/api/payment/${paymentId}/capture`)
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(200);
expect(res.body.data.status).toBe('captured');
});

it('rejects refunding a payment that is still pending', async () => {
const { paymentId } = await setupPendingPayment(userId, token);

const res = await request(app)
.post(`/api/payment/${paymentId}/refund`)
.set('Authorization', `Bearer ${token}`);

expect(res.status).toBe(400);
expect(res.body.error.code).toBe('INVALID_PAYMENT_STATE');
});
});

Key Takeaways​

  • Attach domain-rules.md as context — it gives Copilot the exact error codes and boundaries it needs
  • Always assert both status code AND body shape — neither alone is sufficient
  • Test the checkout pipeline end-to-end: happy path, invalid discount, fraud block, payment lifecycle
  • Integration tests should reset and seed state in beforeEach to prevent order-dependent failures
  • Speed comes from scaffolding; trust comes from asserting the right domain values
Done with this step? Mark it to track your progress.