Przejdź do treści

Testy E2E Cypress w Next.js App Router i GitHub Actions

Praktyczny setup Cypress w Next.js App Router: prawdziwe testy E2E, Server Components, Server Actions, logowanie, dane testowe i GitHub Actions.

Maciej Sala

Founder StriveLab

7 min czytaniaOpublikowano 31 października 2025 (Aktualizacja 1 września 2026)

Najpierw ustal, co naprawdę testujesz

Pod wspólną nazwą E2E często kryją się scenariusze o zupełnie innym zasięgu. Test bez sztucznych danych przechodzi przez przeglądarkę, Next.js i prawdziwy backend. Z kolei test, w którym cy.intercept() podsuwa gotowe dane, omija API oraz bazę – staje się wtedy szybkim testem samego interfejsu, który nie weryfikuje już poprawności działania całego systemu.

Rodzaj scenariuszaWłasny backendTypowe zastosowanie
Prawdziwy E2Edziała naprawdęlogowanie, checkout, zapis krytycznych danych
UI ze stubemodpowiedź zwraca cy.intercept()błędy 500, puste listy, opóźnienia
Usługa zewnętrznasandbox lub stubpłatności, e-mail, API partnera

Najpraktyczniejszy zestaw składa się z kilku w pełni rzeczywistych ścieżek sukcesu (tzw. happy paths) oraz większej liczby szybkich testów ze sztucznymi danymi dla rozmaitych wariantów interfejsu. Dzięki temu pakiet zachowuje stabilność, a jednocześnie bezbłędnie wykrywa zerwany kontrakt między klientem a serwerem.

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, // workflow CI nadpisze tę opcję
    screenshotOnRunFailure: true,
    retries: {
      runMode: 2,
      openMode: 0,
    },
  },
})

Kilka porad:

  • Wykorzystuj baseUrl z umiarem, bo jest to opcja konfiguracyjna Cypressa, a nie wymóg App Routera, który służy po prostu do tego, by pisać cy.visit('/products') zamiast pełnego adresu URL.

  • Nie manipuluj globalnym timeoutem. Zwiększanie go tylko po to, by ukryć problemy z synchronizacją lub niestabilnym stanem aplikacji, to leczenie objawów, a nie przyczyn problemów.

  • Nie ufaj mechanizmowi powtórzeń (retries), ponieważ ponowienia nie naprawiają chwiejnych testów (flaky tests), a jedynie maskują fakt, że test nie jest w pełni deterministyczny.

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": "npm run build && start-server-and-test start http://localhost:3000 cy:run"
  }
}

Narzędzie start-server-and-test uruchamia serwer, czeka, aż będzie gotowy, i dopiero wtedy przechodzi do testów. Z kolei komenda w CI samodzielnie buduje aplikację, dzięki czemu cały proces nie wymaga żadnych wcześniejszych, ręcznych kroków:

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

Fixtures: szybki test UI, nie pełne E2E

Dane testowe to statyczne zasoby wykorzystywane do stubowania żądań wychodzących z przeglądarki. Świetnie sprawdzają się przy symulowaniu pustych list, błędów czy rozmaitych stanów interfejsu, ale całkowicie pomijają rzeczywistą odpowiedź backendu. Oznacza to, że poniższy scenariusz nie potwierdza ani poprawnego działania endpointu /api/products, ani tego, że produkcyjny backend zwraca identyczny kontrakt danych.

Plik cypress/fixtures/api-responses/products.json może na przykład zawierać dwa przykładowe produkty. Nie trzeba go wcześniej ładować do osobnej zmiennej ani przypisywać mu aliasu, ponieważ cy.intercept() potrafi obsłużyć plik fixture bezpośrednio:

Code
// cypress/e2e/products/product-list.cy.ts
describe('Product List', () => {
  it('displays products from a fixture', () => {
    cy.intercept('GET', '/api/products', {
      fixture: 'api-responses/products.json',
    }).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ł')
  })
})

Dane testowe i logowanie programowe

Rzetelne testy E2E wymagają przewidywalnego backendu i dlatego, najlepiej resetować bazę danych w bloku beforeEach, a następnie wstrzykiwać wymagany zestaw danych za pomocą cy.task(). Implementacja takiego taska stanowi swego rodzaju adapter projektowy – inaczej będzie wyglądać dla Prismy, Drizzle czy Supabase. Każda wersja musi jednak bezwzględnie sprawdzać warunek APP_ENV === 'test' i odmawiać działania w przypadku wykrycia produkcyjnego adresu bazy.

Czyszczenie bazy przed testem jest bezpieczniejsze niż sprzątanie w afterEach. W sytuacji, kiedy poprzedni test lub sam runner ulegnie awarii, kolejny scenariusz i tak wystartuje z pewnego, znanego stanu.

Adapter bazy danych zarejestruj wewnątrz setupNodeEvents. Importowane poniżej funkcje należą do kodu aplikacji i to one powinny fizycznie wykonywać reset oraz zasilenie bazy danymi:

Code
// cypress.config.ts
import { defineConfig } from 'cypress'
import { resetTestDatabase, seedScenario } from './cypress/tasks/database'
 
export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on) {
      if (process.env.APP_ENV !== 'test') {
        throw new Error('Database tasks require APP_ENV=test')
      }
 
      on('task', {
        'db:reset': async () => {
          await resetTestDatabase()
          return null
        },
        'db:seed': async (scenario: string) => {
          await seedScenario(scenario)
          return null
        },
      })
    },
  },
})

Logowanie przez UI zostaw dla testu samego procesu logowania, ponieważ pozostałe testy mogą użyć endpointu aplikacji i zachować wynik przez cy.session(). Poniższe adresy są przykładowym kontraktem projektu, a nie wbudowanymi endpointami Next.js lub Auth.js.

Code
// cypress/support/commands.ts
 
Cypress.Commands.add('login', () => {
  return cy
    .env(['TEST_USER_EMAIL', 'TEST_USER_PASSWORD'])
    .then(({ TEST_USER_EMAIL: email, TEST_USER_PASSWORD: password }) => {
      return cy.session(
        ['api-login', email],
        () => {
          cy.request('POST', '/api/test-auth/login', { email, password })
        },
        {
          validate() {
            cy.request('/api/test-auth/session').its('status').should('eq', 200)
          },
        },
      )
    })
})

Type definitions dla custom commands

Code
// cypress/support/types.d.ts
declare namespace Cypress {
  interface Chainable {
    login(): Chainable<null>
  }
}

W sytuacji, kiedy odpowiedź logowania ustawia ciasteczko HttpOnly, cy.request() automatycznie zapisuje nagłówek Set-Cookie w pamięci przeglądarki, w związku z czym nie trzeba ręcznie kopiować tokenu do localStorage. Pamiętaj tylko, że endpoint do testowego logowania musi być bezwzględnie niedostępny poza środowiskiem testowym.

Z kolei w projektach opartych na Auth.js zamiast tego rozwiązania możesz owinąć standardowy proces uwierzytelniania albo zainicjować sesję bezpośrednio przez adapter bazy danych.

Granice App Routera w testach Cypress

Loading states i Suspense w testach E2E

Najpierw ustal, gdzie wykonywany jest request. cy.intercept() przechwytuje ruch wychodzący z przeglądarki, więc poniższy test jest poprawny dla Client Component:

Code
it('shows an error when the client-side request fails', () => {
  cy.intercept('GET', '/api/products', {
    statusCode: 500,
    body: { message: 'Service unavailable' },
  }).as('getProducts')
 
  cy.visit('/products/client-view')
  cy.wait('@getProducts')
  cy.get('[role="alert"]').should('contain.text', 'Nie udało się pobrać')
})

Wywołanie fetch() w komponencie serwerowym wykonuje się w procesie Next.js i w ogóle nie przechodzi przez proxy przeglądarki. Dane do takiego testu przygotujesz za pomocą cy.task('db:seed', ...), a następnie sprawdzisz rezultat po wywołaniu cy.visit(). Kiedy musisz w sposób deterministyczny wymusić wyświetlenie pliku loading.tsx, dodaj kontrolowane opóźnienie w testowym adapterze backendu – przeglądarkowy cy.intercept() nie wpłynie na żądanie fetch() wykonywane po stronie serwera.

Cypress ponawia zapytania do DOM i sprawdza, czy element nadaje się do interakcji, dlatego nie dodawaj globalnego waitForHydration(). W sytuacji, kiedy aplikacja naprawdę renderuje aktywny przycisk przed podpięciem handlera Reacta, wystaw jawny znacznik gotowości dla tej części UI i testuj ten konkretny kontrakt.

Server Actions w testach E2E

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

Testuj efekt widoczny dla użytkownika, a nie mechanizm. Sam toast jest za słabą asercją, ponieważ może pojawić się po optymistycznej aktualizacji. Przeładowanie strony potwierdzi, że zmiana dotarła do backendu:

Code
it('submits form via Server Action', () => {
  cy.task('db:reset')
  cy.task('db:seed', 'user-with-profile')
  cy.login()
  cy.visit('/settings')
 
  cy.get('[name="displayName"]').clear().type('Nowa nazwa')
  cy.get('[data-cy="save-profile"]').click()
 
  cy.get('[role="status"]').should('contain.text', 'Ustawienia zapisane')
  cy.reload()
  cy.get('[name="displayName"]').should('have.value', 'Nowa nazwa')
})

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('uses a modal for soft navigation and a page after reload', () => {
  cy.visit('/products')
  cy.get('[data-cy="product-link"]').first().click()
 
  cy.get('[role="dialog"]').should('be.visible')
  cy.location('pathname').should('match', /^\/products\/[^/]+$/)
 
  cy.reload()
  cy.get('[role="dialog"]').should('not.exist')
  cy.get('main h1').should('be.visible')
})

Ten test obejmuje najważniejszą różnicę Intercepting Routes: miękka nawigacja otwiera modal, natomiast bezpośrednie żądanie dokumentu renderuje pełną stronę.

Sekrety i konfiguracja środowiska

Nie zapisuj haseł w cypress.config.ts. Lokalnie możesz użyć ignorowanego przez Git pliku cypress.env.json, a w GitHub Actions przechowuj wartości w Secrets i przekazuj je jako CYPRESS_TEST_USER_EMAIL oraz CYPRESS_TEST_USER_PASSWORD. Cypress usuwa prefiks CYPRESS_, dlatego test odczytuje klucze TEST_USER_EMAIL i TEST_USER_PASSWORD przez cy.env().

Oddziel dane jawne (takie jak nazwa środowiska) od poufnych sekretów. Nigdy nie wykonuj bezpośrednich asercji na haśle czy tokenie – w razie błędu ich wartości mogłyby wylądować w logach CI.

Cypress E2E w GitHub Actions CI

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
    env:
      APP_ENV: test
      DATABASE_URL: ${{ secrets.E2E_DATABASE_URL }}
      CYPRESS_TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
      CYPRESS_TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
 
    steps:
      - name: Checkout
        uses: actions/checkout@v6
 
      - 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@v7
        with:
          install: false
          start: npm start
          wait-on: 'http://localhost:3000'
          wait-on-timeout: 120
          browser: chrome
          config: video=true
      - name: Upload Cypress artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-artifacts
          path: |
            cypress/screenshots
            cypress/videos
          if-no-files-found: ignore
          retention-days: 7

Testy CI uruchamiaj przeciwko next build + next start, ponieważ serwer deweloperski nie odtwarza dokładnie produkcyjnego routingu, bundlingu i cache. Zmienne potrzebne podczas buildu i działania serwera ustawiaj na poziomie joba, nie tylko kroku Cypress.

Sekrety GitHub nie są automatycznie udostępniane w procesach uruchamianych z forków. W sytuacji, kiedy Twoje repozytorium przyjmuje zewnętrzne pull requesty, podziel proces na dwie części: bezpieczny zestaw testów bez dostępu do sekretów oraz pełny pakiet E2E, który rusza dopiero po zatwierdzeniu zmian.

Pamiętaj też, że opisane zadanie odpowiada za CI, a nie za CD. Wdrożenie powinno być osobnym etapem z klauzulą needs: e2e lub osobnym procesem uruchamianym dopiero po uzyskaniu zielonego buildu. Sam job testów E2E warto ustawić jako wymagany status check dla chronionej gałęzi – dzięki temu nikt nie ominie ich przy scalaniu pull requesta.

Optymalizacja czasu testów E2E w CI

Wprowadź paralelizację dopiero wtedy, gdy czas wykonywania testów zacznie realnie blokować pracę zespołu. Cypress Cloud potrafi automatycznie rozdzielać pliki testowe pomiędzy maszyny, a bez płatnej wersji można rozbić foldery na osobne zadania za pomocą macierzy w CI. Oba te rozwiązania wiążą się jednak z większym kosztem konfiguracji i na niewiele się zdadzą, jeśli Twoje testy są zależne od współdzielonego stanu.

Co powinno wejść do pierwszego zestawu

Na start w zupełności wystarczą logowanie lub rejestracja, kluczowy zapis przez Server Action, proces zakupu (checkout) lub główna funkcja produktu, jeden test nawigacji w App Routerze oraz jeden przypadek kontrolowanego błędu obsłużony przez cy.intercept().

Atrybuty data-cy wybieraj wtedy, gdy treść elementu może się zmieniać bez wpływu na jego działanie. Z kolei wyszukiwanie po tekście lub roli sprawdza się lepiej, gdy to właśnie one są kluczowym wymaganiem, przykładowo w przypadku komunikatów o błędach czy dostępnych etykiet przycisków. Poza tym, unikaj również sztywnych opóźnień cy.wait(ms) na rzecz aliasów zapytań lub asercji weryfikujących widoczny efekt na ekranie.

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.

Czy baseUrl jest wymagane przez Next.js App Router?

Nie. baseUrl jest opcją Cypressa, która pozwala używać krótkich adresów, takich jak cy.visit('/checkout'), zamiast pełnego URL. Nie jest wymaganiem App Routera, ale upraszcza konfigurację lokalną i CI.

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()) i odtwarzania trudnych stanów interfejsu. Test ze stubowaną odpowiedzią nie sprawdza jednak prawdziwego API ani bazy, dlatego krytyczne ścieżki powinny mieć również scenariusz bez fixture.

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, aby ujawnić sporadyczne problemy. Test, który przechodzi dopiero przy ponowieniu, nadal wymaga naprawy.

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. Nie wykonuje jednak logowania samodzielnie: funkcja setup nadal musi użyć API lub UI, a validate powinno potwierdzić, że odtworzona sesja jest aktywna.

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 prefiksu CYPRESS_ w sekcji env workflowa, a wartości przechowuj w GitHub Secrets. W Cypress 15.10+ odczytuj poufne wartości przez cy.env(), ponieważ synchroniczne Cypress.env() jest przestarzałe.

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@v7 zamiast ręcznego konfigurowania: dostarcza parametry start (komenda uruchamiająca serwer) i wait-on (URL do odczekania przed startem testów). Artefakty (screenshoty i nagrania wideo) uploaduj przez actions/upload-artifact tylko przy błędzie. Dla dużych zestawów testów rozważ paralelizację przez Cypress Cloud albo 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ą
LH.pl – Hosting Mango

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

LH.pl – Cloud Server 1C4G