Przejdź do treści

E2E testy w Next.js App Router – kompletny setup Cypress + CI/CD

Flaky testy E2E blokują CI zamiast go zabezpieczać. Jak ustawić Cypress w Next.js App Router, żeby testy były stabilne i szybkie?

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/ — izolują testy od stanu backendu i eliminują 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 — logowanie, nawigacja, 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ć — 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 — 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 — 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 — to 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 — 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 — 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 — kosztują czas i zaufanie do zestawu.

Seeduj bazę danych przed testamicy.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 — 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 — 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 — 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 tylko if: failure(), nagrania if: always(). Dla dużych zestawów testów rozważ paralelizację przez matrix strategy.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend 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 rozwijam 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

Cypress vs Playwright – który wybrać do projektu 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

Automatyczne testy regresji SEO w GitHub Actions — noindex, link kanoniczny i redirecty pod kontrolą CI/CD

Jedna linijka w niewłaściwym miejscu — , która miała zostać tylko na środowisku testowym — i strona znika z Google na tygodnie. To jeden z najdroższych błędów w SEO technicznym, ponieważ długo pozostaje niewidoczny: build przechodzi, strona działa, użytkownicy niczego nie zauważają, a ruch organiczny po cichu się osuwa. Dobra wiadomość jest taka, że tę klasę błędów da się złapać automatycznie, zanim kod w ogóle trafi na produkcję. W tym artykule pokazuję, jak zbudować testy regresji SEO w Playwright i wpiąć je w GitHub Actions , żeby pull request z zepsutym linkiem kanonicznym czy przypadkowym noindexem po prostu nie przeszedł.

Maciej Sala

Maciej Sala

Founder StriveLab