The brief told you what to build. This lesson walks you through how to build it — nine concrete steps from npm init to a green GitHub Actions check, with real TypeScript code at every step. Follow it linearly the first time. Once the framework layer (steps 1-4) is in place, the test files (step 5) come quickly and you can adapt the rest.
Step 1 — Project setup
mkdir shopeasy-tests && cd shopeasy-tests
npm init -y
npm install --save-dev \
cypress typescript \
@faker-js/faker \
axe-core cypress-axe \
mochawesome mochawesome-merge mochawesome-report-generator \
cypress-multi-reporters mocha-junit-reporter
npx cypress openPick E2E, Chrome, accept the scaffold. Configure cypress.config.ts with multi-target env (chapter 5):
import { defineConfig } from "cypress";
const targets = {
dev: { baseUrl: "http://localhost:3000", apiUrl: "http://localhost:3000/api" },
staging: { baseUrl: "https://staging.shopeasy.io", apiUrl: "https://staging-api.shopeasy.io" },
} as const;
const target = (process.env.CYPRESS_TARGET ?? "dev") as keyof typeof targets;
const active = targets[target];
export default defineConfig({
e2e: {
baseUrl: active.baseUrl,
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 6000,
video: true,
retries: { runMode: 2, openMode: 0 },
reporter: "cypress-multi-reporters",
reporterOptions: {
reporterEnabled: "spec, mochawesome, mocha-junit-reporter",
mochawesomeReporterOptions: {
reportDir: "cypress/reports",
overwrite: false,
html: false,
json: true,
},
mochaJunitReporterReporterOptions: {
mochaFile: "cypress/reports/junit/[hash].xml",
},
},
env: { apiUrl: active.apiUrl, target },
},
});Folder structure (chapter 9):
cypress/
├── e2e/{smoke,auth,products,cart,checkout,admin,api}/
├── fixtures/{users,products,api-responses}/
├── pages/
├── support/{commands,types.ts,e2e.ts}
└── utils/{factories.ts,constants.ts,api.ts,dateUtils.ts}
Step 2 — Type definitions and factories
// cypress/support/types.ts
export type UserRole = "admin" | "standard" | "guest";
export interface User { id: number; name: string; email: string; role: UserRole }
export interface Product { id: number; name: string; price: number; category: string; inStock: boolean }
export interface CartItem { productId: number; quantity: number }
export interface Order { id: number; userId: number; items: CartItem[]; total: number; status: "pending" | "paid" | "shipped" }
export interface ApiResponse<T> { status: number; data: T; message?: string }// cypress/utils/factories.ts
import { faker } from "@faker-js/faker";
import type { User, Product, Order, CartItem } from "../support/types";
let counter = 0;
export function createUser(overrides: Partial<User> = {}): User {
counter++;
return {
id: counter + Date.now(),
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
role: "standard",
...overrides,
};
}
export function createProduct(overrides: Partial<Product> = {}): Product {
counter++;
return {
id: counter + Date.now(),
name: faker.commerce.productName(),
price: parseFloat(faker.commerce.price()),
category: faker.commerce.department().toLowerCase(),
inStock: true,
...overrides,
};
}
export function createOrder(overrides: Partial<Order> = {}): Order {
counter++;
return {
id: counter + Date.now(),
userId: 1,
items: [],
total: 0,
status: "pending",
...overrides,
};
}
export function createOrderWithProducts(count = 2): { order: Order; products: Product[] } {
const products = Array.from({ length: count }, () => createProduct());
const order = createOrder({
items: products.map((p) => ({ productId: p.id, quantity: 1 })),
total: products.reduce((s, p) => s + p.price, 0),
});
return { order, products };
}Step 3 — Custom commands
// cypress/support/commands/auth.ts
import type { User } from "../types";
declare global {
namespace Cypress {
interface Chainable {
apiLogin(email: string, password: string): Chainable<void>;
sessionLogin(email: string, password: string): Chainable<void>;
uiLogin(email: string, password: string): Chainable<void>;
}
}
}
Cypress.Commands.add("apiLogin", (email, password) => {
cy.request("POST", `${Cypress.env("apiUrl")}/auth/login`, { email, password })
.its("body.token")
.then((token) => cy.setCookie("auth_token", token));
});
Cypress.Commands.add("sessionLogin", (email, password) => {
cy.session(
[email, password],
() => cy.apiLogin(email, password),
{
cacheAcrossSpecs: true,
validate: () =>
cy
.request({ url: `${Cypress.env("apiUrl")}/users/me`, failOnStatusCode: false })
.its("status")
.should("eq", 200),
},
);
});
Cypress.Commands.add("uiLogin", (email, password) => {
cy.visit("/login");
cy.get("[data-testid='email']").type(email);
cy.get("[data-testid='password']").type(password, { log: false });
cy.get("[data-testid='submit']").click();
});
export {};// cypress/support/commands/cart.ts
declare global {
namespace Cypress {
interface Chainable {
addToCart(productName: string, quantity?: number): Chainable<void>;
checkout(card: { number: string; expiry: string; cvc: string }): Chainable<void>;
}
}
}
Cypress.Commands.add("addToCart", (productName, quantity = 1) => {
cy.contains("[data-testid='product-card']", productName).within(() => {
if (quantity > 1) cy.get("[data-testid='qty']").clear().type(String(quantity));
cy.contains("button", "Add to cart").click();
});
});
Cypress.Commands.add("checkout", (card) => {
cy.get("[data-testid='card-number']").type(card.number);
cy.get("[data-testid='card-expiry']").type(card.expiry);
cy.get("[data-testid='card-cvc']").type(card.cvc, { log: false });
cy.get("[data-testid='pay-btn']").click();
});
export {};// cypress/support/commands/index.ts
export * from "./auth";
export * from "./cart";// cypress/support/e2e.ts
import "cypress-axe";
import "./commands";Step 4 — Page objects
// cypress/pages/loginPage.ts
export const loginPage = {
visit: () => cy.visit("/login"),
emailInput: () => cy.get("[data-testid='email']"),
passwordInput: () => cy.get("[data-testid='password']"),
submitButton: () => cy.get("[data-testid='submit']"),
errorMessage: () => cy.get("[data-testid='login-error']"),
login: (email: string, password: string) => {
loginPage.emailInput().type(email);
loginPage.passwordInput().type(password, { log: false });
loginPage.submitButton().click();
},
};Repeat the pattern for productListPage, cartPage, checkoutPage. Each one exposes element accessors as functions and a few high-level composite actions.
Step 5 — Sample tests
Authentication suite:
// cypress/e2e/auth/login.cy.ts
import { loginPage } from "../../pages/loginPage";
describe("Login", () => {
beforeEach(() => loginPage.visit());
it("logs in with valid credentials", () => {
loginPage.login("alice@test.com", "Sup3rS3cret!");
cy.url().should("include", "/dashboard");
});
it("shows an error on invalid credentials", () => {
loginPage.login("alice@test.com", "wrong");
loginPage.errorMessage().should("contain", "Invalid credentials");
});
it("disables submit when email is empty", () => {
loginPage.passwordInput().type("password");
loginPage.submitButton().should("be.disabled");
});
it("persists the session after a refresh", () => {
loginPage.login("alice@test.com", "Sup3rS3cret!");
cy.url().should("include", "/dashboard");
cy.reload();
cy.url().should("include", "/dashboard");
});
it("logs the user out", () => {
loginPage.login("alice@test.com", "Sup3rS3cret!");
cy.get("[data-testid='logout']").click();
cy.url().should("include", "/login");
});
});Checkout suite (end-to-end with API setup + intercepts):
// cypress/e2e/checkout/checkout.cy.ts
import { createOrderWithProducts } from "../../utils/factories";
describe("Checkout — full flow", () => {
beforeEach(() => {
cy.sessionLogin("alice@test.com", "Sup3rS3cret!");
const { products } = createOrderWithProducts(2);
products.forEach((p) =>
cy.request("POST", `${Cypress.env("apiUrl")}/test/products`, p),
);
cy.intercept("POST", "**/api/orders").as("createOrder");
cy.intercept("POST", "**/api/payments").as("processPayment");
});
it("places an order through the full checkout", () => {
cy.visit("/products");
cy.contains("[data-testid='product-card']", /./).first()
.find("button").contains("Add to cart").click();
cy.get("[data-testid='nav-cart']").click();
cy.get("[data-testid='checkout-btn']").click();
cy.get("[data-testid='address']").type("123 Test St");
cy.get("[data-testid='postcode']").type("SW1A 1AA");
cy.get("[data-testid='next-step']").click();
cy.checkout({ number: "4242424242424242", expiry: "12/29", cvc: "123" });
cy.wait("@createOrder").its("response.statusCode").should("eq", 201);
cy.wait("@processPayment").its("response.statusCode").should("eq", 200);
cy.contains("Thank you for your order").should("be.visible");
});
});API suite (direct cy.request):
// cypress/e2e/api/products-api.cy.ts
describe("Products API", () => {
it("returns the expected product shape", () => {
cy.request<{ data: unknown[] }>("GET", `${Cypress.env("apiUrl")}/products`)
.then((response) => {
expect(response.status).to.eq(200);
expect(response.body.data).to.be.an("array").and.to.have.length.greaterThan(0);
});
});
it("rejects unauthenticated POSTs", () => {
cy.request({
method: "POST",
url: `${Cypress.env("apiUrl")}/products`,
body: { name: "X", price: 1 },
failOnStatusCode: false,
}).its("status").should("eq", 401);
});
});Step 6 — Edge-case stubs
// cypress/e2e/products/empty-state.cy.ts
it("shows the empty state when no products are returned", () => {
cy.intercept("GET", "**/api/products", { statusCode: 200, body: [] }).as("empty");
cy.visit("/products");
cy.wait("@empty");
cy.get("[data-testid='empty-state']").should("contain", "No products yet");
});// cypress/e2e/checkout/payment-failure.cy.ts
it("shows an error banner when payment fails with 500", () => {
cy.intercept("POST", "**/api/payments", {
statusCode: 500,
body: { error: "Payment provider unavailable" },
}).as("paymentError");
// ... drive to payment step ...
cy.checkout({ number: "4242424242424242", expiry: "12/29", cvc: "123" });
cy.wait("@paymentError");
cy.get("[data-testid='payment-error']").should("contain", "Payment provider unavailable");
});// cypress/e2e/products/slow-load.cy.ts
it("renders the loading spinner before slow products arrive", () => {
cy.intercept("GET", "**/api/products", { statusCode: 200, body: [], delay: 2500 }).as("slow");
cy.visit("/products");
cy.get("[data-testid='loading-spinner']").should("be.visible");
cy.wait("@slow");
cy.get("[data-testid='loading-spinner']").should("not.exist");
});Step 7 — Accessibility checks
// cypress/e2e/a11y.cy.ts
const pages = [
{ path: "/", name: "Homepage" },
{ path: "/products", name: "Product list" },
{ path: "/checkout/shipping", name: "Checkout — shipping" },
];
describe("Accessibility — site-wide", () => {
pages.forEach(({ path, name }) => {
it(`has no critical/serious violations on ${name}`, () => {
cy.sessionLogin("alice@test.com", "Sup3rS3cret!");
cy.visit(path);
cy.injectAxe();
cy.checkA11y(null, { includedImpacts: ["critical", "serious"] });
});
});
});Step 8 — Reporting
package.json scripts (chapter 7, lesson 4):
{
"scripts": {
"cy:open": "cypress open",
"cy:run": "cypress run",
"cy:report:clean": "rm -rf cypress/reports && mkdir -p cypress/reports",
"cy:report:merge": "mochawesome-merge cypress/reports/*.json > cypress/reports/merged.json",
"cy:report:html": "marge cypress/reports/merged.json --reportDir cypress/reports/html --inline",
"cy:report": "npm run cy:report:merge && npm run cy:report:html",
"test": "npm run cy:report:clean && npm run cy:run; npm run cy:report"
}
}Wire screenshot attachment in cypress/support/e2e.ts:
import addContext from "mochawesome/addContext";
Cypress.on("test:after:run", (test, runnable) => {
if (test.state === "failed") {
addContext(
{ test },
`../screenshots/${Cypress.spec.name}/${runnable.parent?.title} -- ${test.title} (failed).png`,
);
}
});Step 9 — CI/CD
.github/workflows/cypress.yml:
name: Cypress
on:
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * *"
jobs:
cypress-run:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
containers: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: cypress-io/github-action@v6
with:
record: true
parallel: true
group: "Chrome — PR"
browser: chrome
build: npm run build
start: npm start
wait-on: "http://localhost:3000"
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_BASE_URL: ${{ secrets.STAGING_URL }}
CYPRESS_ADMIN_PASSWORD: ${{ secrets.STAGING_ADMIN_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate Mochawesome report
if: always()
run: npm run cy:report
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-${{ matrix.containers }}
path: |
cypress/screenshots
cypress/videos
cypress/reports/html
retention-days: 7The build timeline
Step 1 of 5
Scaffold
npm init + cypress install + cypress.config.ts with multi-target env. Folder structure mirrors chapter 9.
What "done" looks like
Run npm test locally — green. Open cypress/reports/html/merged.html — pass/fail summary, embedded screenshots on failures, every test listed. Push to GitHub — Actions runs the four-container matrix and posts a green check on the PR. Open Cypress Cloud — see the recorded run with timing, replay, and flake stats.
If all four work, the framework is complete. The next lesson is the self-assessment — what you should have built, the architecture decisions to reflect on, the stretch goals that turn the project into a portfolio piece, and where to go after this course.
🛠️ Project work
Build the nine steps above. Don't skim them — open the editor and type the code. Use Sauce Demo or your own ShopEasy as the target. Treat each step as its own commit so the git history reads as the build timeline. The first time through usually takes 6-10 hours of focused work, split across two or three sessions.
When you've got 25 tests green, the report rendering, and the Actions check posting, move on to lesson 3 for the review pass.