API & Integration Tests
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.
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.
Primary files
workshop-exercises/tests/api/checkout.test.tsworkshop-exercises/.copilot/context/domain-rules.mdworkshop-exercises/src/openapi.ts
Recovery checkpoint
solutions
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.
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:
| Service | Key Routes | What to test |
|---|---|---|
| Cart | POST /api/cart/:userId/items | Add item, merge duplicate products, reject invalid price/quantity |
| Discount | POST /api/discount/apply | Valid codes, expired codes, unknown codes, minimum-order gate |
| Fraud | POST /api/fraud/check | Low-risk approval, high-risk block, blacklisted country |
| Payment | POST /api/payment/capture | Successful 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:
- TypeScript / Supertest
- Python / pytest
- Java / RestAssured
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.
Write pytest integration tests for the checkout pipeline.
Routes:
- POST /api/cart/{user_id}/items — add item, assert 201 + subtotal
- POST /api/discount/apply — SAVE10 success, BOGUS → INVALID_DISCOUNT_CODE error
- POST /api/fraud/check — high-risk (amount>1000, country=XX) → approved: False
Use the requests library or TestClient. Assert status AND body structure.
Write RestAssured + JUnit 5 integration tests for the checkout pipeline.
Routes: POST /api/cart/{userId}/items, POST /api/discount/apply, POST /api/fraud/check.
Use @MockBean for dependencies. Test success cases, error codes, and fraud blocking.
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');
});
});
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, notNOT_FOUND)? - Does the fraud test verify
riskLevelandapproved— not just one field? - Is the cart merge test asserting
itemslength, not just status?
Step 3 — Run the API Tests
npm run test:api
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
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));"
] }
}]
}
]
}
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 bothpm.response.codeand a parsed field from the body — not one alone? - Does the unknown-discount-code test check
error.code === 'INVALID_DISCOUNT_CODE'fromdomain-rules.md, not just a 4xx status? - Does the fraud test assert
approved: falsefor the high-risk case, matchingtests/api/checkout.test.ts? - Would
npx newman run collection.jsonactually pass against the running dev server?
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
pendingintent returnsINVALID_PAYMENT_STATEwith HTTP 400 - Assert the
error.codein 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.mdas 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
beforeEachto prevent order-dependent failures - Speed comes from scaffolding; trust comes from asserting the right domain values