Przejdź do treści

E2E testy Cypress w Next.js App Router oraz CI CD

Niestabilne testy E2E blokują potok CI CD zamiast go zabezpieczać. Zobacz jak skonfigurować Cypress w Next.js App Router aby testy były zawsze szybkie i powtarzalne.

Maciej Sala

Founder StriveLab

4 min czytaniaOpublikowano 31 października 2025 (Aktualizacja 25 maja 2026)

Krok 1: instalacja i konfiguracja Cypress E2E

Code
npm install -D cypress
npx cypress open

Wybierz E2E TestingChrome → Cypress wygeneruje strukturę plików.

Code
// cypress.config.ts
import { defineConfig } from 'cypress'
 
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
    supportFile: 'cypress/support/e2e.ts',
    viewportWidth: 1280,
    viewportHeight: 720,
    video: false, // włącz w CI
    screenshotOnRunFailure: true,
    defaultCommandTimeout: 10000,
    retries: {
      runMode: 2, // retries w CI
      openMode: 0, // brak retries lokalnie
    },
  },
})

TypeScript i tsconfig dla Cypress

Code
// cypress/tsconfig.json
{
  "compilerOptions": {
    "target": "es2017",
    "lib": ["es2017", "dom", "dom.iterable"],
    "types": ["cypress", "node"],
    "baseUrl": "..",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["**/*.ts", "**/*.tsx"]
}

Skrypty Cypress w package.json

Code
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "cy:open": "cypress open",
    "cy:run": "cypress run",
    "test:e2e": "start-server-and-test dev http://localhost:3000 cy:run",
    "test:e2e:open": "start-server-and-test dev http://localhost:3000 cy:open",
    "test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run"
  }
}

start-server-and-test uruchamia serwer, czeka aż będzie dostępny i odpala testy:

Code
npm install -D start-server-and-test

Krok 2: struktura testów E2E w projekcie Next.js

Code
cypress/
├── e2e/
│   ├── auth/
│   │   ├── login.cy.ts
│   │   └── register.cy.ts
│   ├── products/
│   │   ├── product-list.cy.ts
│   │   └── product-detail.cy.ts
│   └── checkout/
│       └── checkout-flow.cy.ts
├── fixtures/
│   ├── user.json
│   └── api-responses/
│       ├── products.json
│       ├── login-success.json
│       └── login-error.json
├── support/
│   ├── commands.ts
│   ├── e2e.ts
│   └── types.d.ts
└── tsconfig.json

Grupuj testy według feature'ów, nie typów stron. W złożonych projektach nawigacja po auth/, products/, checkout/ jest o rząd wielkości szybsza niż szukanie testu w płaskiej liście plików.

Krok 3: fixtures i dane testowe

Fixtures to statyczne pliki JSON z danymi testowymi. Trzymaj je w cypress/fixtures/, żeby izolować testy od stanu backendu i eliminować niedeterministyczne zachowania.

Code
// cypress/fixtures/user.json
{
  "validUser": {
    "email": "test@example.com",
    "password": "Test1234!",
    "name": "Jan Testowy"
  },
  "invalidUser": {
    "email": "wrong@example.com",
    "password": "bad"
  }
}
Code
// cypress/fixtures/api-responses/products.json
{
  "products": [
    {
      "id": "1",
      "name": "Laptop ThinkPad X1",
      "price": 599900,
      "category": "laptopy",
      "inStock": true
    },
    {
      "id": "2",
      "name": "Monitor Dell 27\"",
      "price": 149900,
      "category": "monitory",
      "inStock": false
    }
  ]
}

Użycie w teście:

Code
// cypress/e2e/products/product-list.cy.ts
describe('Product List', () => {
  beforeEach(() => {
    cy.fixture('api-responses/products.json').as('productsData')
  })
 
  it('displays products from API', function () {
    cy.intercept('GET', '/api/products', this.productsData).as('getProducts')
 
    cy.visit('/products')
    cy.wait('@getProducts')
 
    cy.get("[data-testid='product-card']").should('have.length', 2)
    cy.contains('Laptop ThinkPad X1')
    cy.contains('5 999,00 zł')
  })
 
  it('shows out of stock badge', function () {
    cy.intercept('GET', '/api/products', this.productsData)
    cy.visit('/products')
 
    cy.contains('Monitor Dell 27')
      .parents("[data-testid='product-card']")
      .find("[data-testid='out-of-stock']")
      .should('be.visible')
  })
})

Krok 4: custom commands w Cypress

Custom commands eliminują powtarzalny kod, taki jak logowanie, nawigacja i setup stanu. Im mniej duplikatu w testach, tym niższy koszt utrzymania całego zestawu.

Code
// cypress/support/commands.ts
 
// Logowanie przez UI (wolne, ale testuje faktyczny flow)
Cypress.Commands.add('loginViaUI', (email: string, password: string) => {
  cy.visit('/login')
  cy.get("[data-testid='email-input']").type(email)
  cy.get("[data-testid='password-input']").type(password)
  cy.get("[data-testid='login-button']").click()
  cy.url().should('not.include', '/login')
})
 
// Logowanie przez API (szybkie, dla testów nie dotyczących auth)
// Uwaga: działa wyłącznie z tokenami w localStorage (JWT). Przy auth opartym
// na HttpOnly cookies (NextAuth, Auth.js) użyj loginViaUI lub cy.session().
Cypress.Commands.add('loginViaAPI', (email: string, password: string) => {
  cy.request({
    method: 'POST',
    url: '/api/auth/login',
    body: { email, password },
  }).then((response) => {
    cy.window().then((win) => {
      win.localStorage.setItem('token', response.body.token)
    })
  })
})
 
// Logowanie przez session caching (najszybsze)
Cypress.Commands.add('login', (email?: string, password?: string) => {
  const userEmail = email ?? Cypress.env('TEST_USER_EMAIL')
  const userPassword = password ?? Cypress.env('TEST_USER_PASSWORD')
 
  cy.session(
    [userEmail],
    () => {
      cy.loginViaAPI(userEmail, userPassword)
    },
    {
      validate: () => {
        // Sprawdź czy sesja jest nadal aktywna; bez tego Cypress może
        // odtwarzać wygasłą sesję bez ponownego logowania
        cy.request({ url: '/api/auth/me', failOnStatusCode: false })
          .its('status')
          .should('eq', 200)
      },
    },
  )
})
 
// Helper do sprawdzania toast notifications
Cypress.Commands.add('shouldShowToast', (message: string) => {
  cy.get("[role='status']").should('contain.text', message)
})

Type definitions dla custom commands

Code
// cypress/support/types.d.ts
declare namespace Cypress {
  interface Chainable {
    loginViaUI(email: string, password: string): Chainable<void>
    loginViaAPI(email: string, password: string): Chainable<void>
    login(email?: string, password?: string): Chainable<void>
    shouldShowToast(message: string): Chainable<JQuery<HTMLElement>>
  }
}

Krok 5: testowanie App Routera w Next.js

Loading states i Suspense w testach E2E

Next.js App Router intensywnie korzysta z loading.tsx i Suspense. Testy muszą to uwzględniać, dlatego sprawdzają nie tylko dane końcowe, ale cały cykl życia komponentu:

Code
it('shows loading skeleton while fetching data', () => {
  cy.intercept('GET', '/api/products', (req) => {
    req.on('response', (res) => {
      res.setDelay(2000) // symuluj wolny response
    })
  })
 
  cy.visit('/products')
 
  // Sprawdź loading state
  cy.get("[data-testid='product-skeleton']").should('be.visible')
 
  // Poczekaj na dane
  cy.get("[data-testid='product-card']", { timeout: 5000 }).should(
    'have.length.at.least',
    1,
  )
 
  // Loading powinien zniknąć
  cy.get("[data-testid='product-skeleton']").should('not.exist')
})

Server Actions w testach E2E

Uzależnianie testów od wewnętrznych mechanizmów Next.js to prosta droga do stworzenia niestabilnego zestawu, który wymaga poprawek po każdej aktualizacji frameworka.

Testuj efekt widoczny dla użytkownika, a nie mechanizm:

Code
it('submits form via Server Action', () => {
  cy.login()
  cy.visit('/settings')
 
  cy.get("[name='displayName']").clear().type('Nowa Nazwa')
  cy.get("[data-testid='save-button']").click()
 
  cy.shouldShowToast('Ustawienia zapisane')
  cy.contains('Nowa Nazwa').should('be.visible')
})

Intercepcję zachowaj do zewnętrznych usług, na przykład gdy formularz wywołuje Stripe, zewnętrzne partnera albo webhook.

Parallel Routes i Intercepting Routes w testach E2E

Code
it('opens product modal via intercepting route', () => {
  cy.visit('/products')
  cy.get("[data-testid='product-card']").first().click()
 
  // Modal powinien się otworzyć (intercepting route)
  cy.get("[role='dialog']").should('be.visible')
  cy.get("[role='dialog']").contains('Laptop ThinkPad X1')
 
  // URL się zmienił
  cy.url().should('include', '/products/1')
 
  // Zamknij modal
  cy.get("[aria-label='Zamknij']").click()
  cy.get("[role='dialog']").should('not.exist')
  cy.url().should('eq', `${Cypress.config('baseUrl')}/products`)
})

Krok 6: environment variables dla testów Cypress

Code
// cypress.config.ts
export default defineConfig({
  e2e: {
    // ...
    env: {
      TEST_USER_EMAIL: 'test@example.com',
      TEST_USER_PASSWORD: 'Test1234!',
      API_URL: 'http://localhost:3000/api',
    },
  },
})

Lub przez plik cypress.env.json (dodaj do .gitignore):

Code
{
  "TEST_USER_EMAIL": "test@example.com",
  "TEST_USER_PASSWORD": "secret123"
}

Krok 7: Cypress E2E w GitHub Actions CI/CD

Code
# .github/workflows/e2e.yml
name: E2E Tests
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
 
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 15
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
 
      - name: Install dependencies
        run: npm ci
 
      - name: Build Next.js
        run: npm run build
 
      - name: Run Cypress E2E tests
        uses: cypress-io/github-action@v6
        with:
          install: false
          start: npm start
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 120
          browser: chrome
          config: video=true
        env:
          CYPRESS_TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          CYPRESS_TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
 
      - name: Upload screenshots (on failure)
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: cypress/screenshots
          retention-days: 7
 
      - name: Upload videos
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: cypress-videos
          path: cypress/videos
          retention-days: 7

Testy CI zawsze uruchamiaj przeciwko next build + next start. Różnica między next dev a buildem produkcyjnym to nie szczegół techniczny, lecz przepaść, która ukrywa błędy routingu, middleware i cache do momentu wdrożenia.

Optymalizacja czasu testów E2E w CI

Parallel execution z Cypress Cloud:

Code
e2e:
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      containers: [1, 2, 3]
  steps:
    # ...
    - uses: cypress-io/github-action@v6
      with:
        record: true
        parallel: true
        group: 'E2E - Chrome'
      env:
        CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Bez Cypress Cloud, podział ręczny:

Code
strategy:
  matrix:
    spec:
      - 'cypress/e2e/auth/**'
      - 'cypress/e2e/products/**'
      - 'cypress/e2e/checkout/**'
steps:
  - uses: cypress-io/github-action@v6
    with:
      spec: ${{ matrix.spec }}

Krok 8: dobre praktyki testów E2E w Next.js

cy.session() do cache'owania logowania skraca testy, bo bez tego każdy test traci czas na powtórne logowanie i rośnie całkowity czas CI.

Testuj critical paths, nie każdą stronę. E2E testy są kosztowne, więc skoncentruj się na flow związanym z funkcjonowaniem biznesu: checkout, signup, core feature.

Selektory data-testid są odporne na refactor. Klasy CSS i teksty zmieniają się przy redesignie i wymagają aktualizacji dziesiątek testów.

Eliminuj cy.wait(ms). Używaj cy.wait("@alias") lub assertions z timeoutem, ponieważ hardkodowane waity to flaky testy, które kosztują czas i zaufanie do zestawu.

Seeduj bazę danych przed testami. cy.task() do setupu i cleanup eliminuje wzajemne zanieczyszczanie testów:

Code
// cypress.config.ts
export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        async seedDatabase() {
          // reset DB, insert test data
          return null
        },
        async cleanDatabase() {
          // cleanup
          return null
        },
      })
    },
  },
})
Code
// W teście:
beforeEach(() => {
  cy.task('seedDatabase')
})
 
afterEach(() => {
  cy.task('cleanDatabase')
})
Testy automatyczne komponentów i E2E w Cypress.
QA & Automation

Często zadawane pytania

Czy testy E2E powinny działać na next dev?

Do lokalnego developmentu można używać next dev, ale w CI testuj zbudowaną aplikację przez next build i next start, bo tylko wtedy odtwarzasz warunki zbliżone do produkcji.

Ile testów E2E warto mieć?

Mniej stabilnych testów pokrywających krytyczne ścieżki jest warte więcej niż duża liczba kruchych scenariuszy zależnych od szczegółów implementacji.

Jak ograniczyć flaky testy w Cypressie?

Kluczem do sukcesu są selektory data-testid, fixtures, custom commands, kontrola stanu testowego, eliminacja arbitralnych timeoutów i uruchamianie testów przeciwko stabilnej wersji aplikacji.

Dlaczego warto pisać testy E2E w Next.js?

Testy E2E weryfikują całą ścieżkę użytkownika, tj. od kliknięcia przycisku, przez żądanie HTTP, aż po zmianę widoczną w UI. W przeciwieństwie do testów jednostkowych nie testują izolowanych funkcji, tylko faktyczne działanie aplikacji w przeglądarce. W Next.js App Router, gdzie renderowanie serwer-klient potrafi być bardziej złożone, testy E2E wychwytują błędy integracyjne, których testy jednostkowe nie obejmują.

Czym są fixtures w Cypress i kiedy ich używać?

Fixtures to statyczne pliki JSON (lub inne) z danymi testowymi, przechowywane w cypress/fixtures/. Używaj ich do mockowania odpowiedzi API (cy.intercept()), dostarczania danych wejściowych formularzy i izolowania testów od zewnętrznych zależności. Dzięki fixtures test jest przewidywalny i powtarzalny niezależnie od stanu backendu.

Jak unikać flaky testów w Cypress?

Nie używaj cy.wait(ms) z hardcoded czasem. Zamiast tego czekaj na aliasowany request (cy.wait('@alias')) lub używaj asercji z timeoutem. Stosuj selektory data-testid zamiast klas CSS lub tekstu. W CI uruchamiaj testy na zbudowanej aplikacji, nie dev serwerze. Włącz retries: { runMode: 2 } dla CI, by odfiltrować sporadyczne problemy środowiskowe.

Jak działa `cy.session()` i dlaczego przyspiesza testy?

cy.session() cache'uje stan sesji (cookies, localStorage, sessionStorage) po pierwszym logowaniu i przywraca go dla kolejnych testów zamiast logować się każdorazowo. Testy wymagające uwierzytelnionego użytkownika nie przechodzą pełnego procesu logowania, bo sesja jest zapisana i odtwarzana, co znacząco skraca czas wykonania całego zestawu.

Jak konfigurować zmienne środowiskowe w Cypress dla CI?

Zmienne podawaj przez env w cypress.config.ts dla wartości niepoufnych lub przez plik cypress.env.json (w .gitignore) lokalnie. W GitHub Actions używaj CYPRESS_ prefix w sekcji env workflowa, a wartości przechowuj w GitHub Secrets. Cypress automatycznie mapuje CYPRESS_FOO na Cypress.env('FOO') w testach.

Czy powinienem testować Server Actions bezpośrednio w Cypress?

Nie. Przywiązywanie testów do wewnętrznego transportu Next.js prowadzi do destabilizacji, bo testy łamią się przy zmianach frameworka. Testuj efekt widoczny dla użytkownika: wypełnij formularz, kliknij zapisz, sprawdź czy pojawił się toast i zaktualizowana treść. Intercepcję stosuj do zewnętrznych usług (Stripe, zewnętrzne API), nie do wewnętrznych mechanizmów Next.js.

Jak zintegrować Cypress z GitHub Actions?

Użyj oficjalnej akcji cypress-io/github-action@v6 zamiast ręcznego konfigurowania: dostarcza parametry start (komenda uruchamiająca serwer) i wait-on (URL do odczekania przed startem testów). Artefakty (screenshoty przy błędach, nagrania wideo) uploaduj przez actions/upload-artifact. Screenshoty dodawaj tylko if: failure(), a nagrania if: always(). Dla dużych zestawów testów rozważ paralelizację przez matrix strategy.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Cypress Component Testing w React i Next.js — kiedy naprawdę ma sens

Cypress Component Testing w React i Next.js bez marketingowej mgły. Kiedy daje przewagę nad RTL, jak go skonfigurować i gdzie kończą się jego możliwości.

Maciej Sala

Maciej Sala

Founder StriveLab

Porównanie Cypress i Playwright dla projektu w Next.js

Dogłębne porównanie Cypress i Playwright w kontekście projektów Next.js. doświadczenie deweloperskie, szybkość, wsparcie dla SSR i React – który framework testowy wybrać?

Maciej Sala

Maciej Sala

Founder StriveLab

Zaawansowane testy E2E z Playwright w Next.js

Playwright idealnie sprawdza się w projektach opartych na Next.js App Router, ponieważ testuje aplikację dokładnie tak, jak widzi ją użytkownik: od uwzględnienia streamingu HTML, przez płynną nawigację, aż po formularze i interakcje. Wbudowane funkcje, takie jak automatyczne oczekiwanie, fixtures, zaawansowane zarządzanie żądaniami oraz testy wizualne, pozwalają kompleksowo zabezpieczyć jakość kodu bez konieczności dokładania kolejnych, zewnętrznych narzędzi.

Maciej Sala

Maciej Sala

Founder StriveLab