Guided Walkthrough — Setup, Page Objects, API Mocking, CI/CD Pipeline

12 min read

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 open

Pick 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: 7

The 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.

// tip to track lessons you complete and pick up where you left off across devices.