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

Cypress E2E w Next.js App Router krok po kroku. Konfiguracja, fixtures, custom commands, CI i wzorce, które ograniczają flaky testy.

Opublikowano

31 października 2025 11:40

Czytanie

5 min czytania

Aktualizacja

7 kwietnia 2026 10:47

Konfiguracja testów E2E w Next.js z App Router nie jest skomplikowana, ale wymaga kilku istotnych decyzji, które wpływają na cały workflow Twojego zespołu. Ten artykuł przeprowadzi Cię przez cały proces ten proces – od npm install do zielonego ✅ w GitHub Actions.

Krótka odpowiedź: Cypress w Next.js App Router skonfiguruj z cypress.config.ts, TypeScript i start-server-and-test do lokalnego uruchamiania. Skup się, by stosować fixtures do danych testowych, custom commands do eliminacji powtarzalnego kodu logowania (z cy.session() do cache'owania sesji) i data-testid selektorów odpornych na refactor w przyszłości. W CI uruchamiaj testy przeciwko zbudowanej aplikacji (next build + next start), a nie next dev - wszystko po to, by pomniejszyć różnicę między testem, a produkcją.

Krok 1: Instalacja i konfiguracja bazowa

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 – tsconfig dla Cypress

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

Skrypty 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"
  }
}

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

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

Krok 2: Struktura projektu

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, a nie typów stron, co ułatwia nawigację w złożonych, dużych projektach.

Krok 3: Fixtures – dane testowe

Fixtures to statyczne pliki JSON z danymi testowymi. Trzymaj je w cypress/fixtures/.

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
    }
  ]
}

Tak wygląda ich 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

Custom commands eliminują powtarzalny kod – logowanie, nawigacja, setup stanu. Warto od razu je wprowadzić, im mniej kodu tym lepiej.

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

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 Router – specyficzne sprawy

Loading states i Suspense

Next.js App Router intensywnie korzysta z loading.tsx i Suspense, musisz je uwzględnić w testach:

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

Przy Server Actions nie warto przywiązywać testów do wewnętrznego transportu Next.js, ponieważ dużo stabilniejszy test sprawdza efekt widoczny dla użytkownika:

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 raczej do zewnętrznych usług, na przykład gdy formularz po drodze wywołuje Stripe, własne API, czyli Application Programming Interface, definiuje sposób komunikacji między aplikacjami lub modułami. partnera albo webhook.

Parallel Routes i Intercepting Routes

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

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

W CI uruchamiaj testy przeciwko zbudowanej aplikacji (next build + next start), nie przeciwko next dev. To zmniejsza różnicę między testem a produkcją i zwykle ogranicza flaky zachowania.

Optymalizacja czasu 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

Używaj cy.session() do cache'owania logowania – bez tego każdy test traci czas na powtórne logowanie.

Testuj critical paths, nie każdą stronę – E2E testy są kosztowne. Skoncentruj się na flow, który generuje przychód (checkout, signup, core feature).

Dodaj data-testid selektory – bardziej odporne na refactor niż klasy CSS czy tekst.

Unikaj cy.wait(ms) – używaj cy.wait("@alias") lub assertions z timeoutem. Hardcoded waity to flaky testy.

Seeduj bazę danych przed testami – użyj cy.task() do setupu i cleanup.

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')
})

FAQ

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, czyli User Interface, to wizualna i interakcyjna warstwa produktu.. 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/ i używaj ich do mockowania odpowiedzi API (cy.intercept()), dostarczania danych wejściowych formularzy i izolowania testów od zewnętrznych zależności. Przy użyciu fixtures, test jest przewidywalny, powtarzalny i to niezależnie od stanu backendu.

Jak unikać flaky testów w Cypress?

Najważniejsze to nie używać 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, które mogą się zmieniać w przyszłości. W CI uruchamiaj testy na zbudowanej aplikacji, a nie dev serwerze (ważne!). Na koniec, włącz retries: { runMode: 2 } dla CI, żeby 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. W ten sposób testy, które wymagają uwierzytelnionego użytkownika, nie muszą przechodzić pełnego procesu logowania, a sesja jest zapisana i odtwarzana, co znacząco skraca czas wykonania całego zestawu testów.

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 (np. intercepcja fetch do Server Actions) prowadzi do destabilizacji testów, które łamią się przy zmianach frameworka. Znacznie lepszym podejściem jest testowanie efektu widocznego dla użytkownika - po prostu wypełnij formularz, potem kliknij zapisz, sprawdź czy pojawił się toast i zaktualizowana treść. Intercepcję stosuj do zewnętrznych usług (Stripe, zewnętrzne API), a 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.

Podsumowanie

Masz teraz gotowy kompletny setup i Cypress skonfigurowany z Next.js App Router, TypeScript, fixtures, custom commands i CI/CD to praktyka automatyzacji testów, buildów i wdrożeń w procesie dostarczania oprogramowania. na GitHub Actions. Od czego zacząć? Zacznij od jednej krytycznej ścieżki użytkownika i testuj efekt, a nie samą implementację, ponieważ lepiej mieć 5 stabilnych testów E2E uruchamianych na buildzie produkcyjnym niż 50 testów związanych z detalami transportu HTTP, które łamią się przy każdej zmianie wewnątrz frameworka.

Źródła i dokumentacja

Pracuję z tym zawodowo.

Jeśli chcesz przełożyć ten temat na lepszą architekturę frontendu, uporządkować React lub Next.js i podnieść jakość pracy zespołu, skontaktuj się ze mną. Pomagam zamieniać wiedzę z artykułów w praktyczne decyzje technologiczne.

O autorze

Maciej Sala

Maciej Sala — project manager i frontendowiec z doświadczeniem w marketingu internetowym. Na co dzień pracuję z Reactem, Next.js i TypeScriptem, łącząc perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat związany z branżą gier wideo jako project manager i game designer.

Absolwent historii na Uniwersytecie Jagiellońskim i studiów podyplomowych z marketingu internetowego na Akademii Górniczo-Hutniczej w Krakowie. Poza pracą trenuje na siłowni, maluje figurki i realizuje własne projekty.

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów
Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Astro.js vs Next.js — które narzędzie wybrać w 2026 roku?

Fachowe porównanie Astro.js i Next.js z perspektywy developera pracującego na co dzień w Next.js. Architektura, wydajność, SEO, DX, koszty i konkretne use case — z benchmarkami i przykładami kodu.

Maciej Sala

Maciej Sala

Founder Strivelab