diff --git a/.env b/.env
index 6f8ed9c..25031d0 100644
--- a/.env
+++ b/.env
@@ -1,2 +1,3 @@
MONGO_DB_URL=
NEXT_PUBLIC_NODE_ENV=
+PLAYWRIGHT_BASE_URL=
diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml
index 557644d..00350de 100644
--- a/.github/workflows/deployment.yml
+++ b/.github/workflows/deployment.yml
@@ -25,4 +25,4 @@ jobs:
run: npm run lint
- name: Unit Tests
- run: npm run test
+ run: npm run test:unit
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
new file mode 100644
index 0000000..9a28a5d
--- /dev/null
+++ b/.github/workflows/playwright.yml
@@ -0,0 +1,43 @@
+name: E2e tests
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ timeout-minutes: 60
+
+ steps:
+ - name: Wait for HTTP Status Code 200 from the Vercel Preview Deploy
+ uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
+ id: waitForVercelPreviewDeploy
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ max_timeout: 300
+
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+
+ - name: Run Playwright tests
+ env:
+ PLAYWRIGHT_BASE_URL: ${{ steps.waitForVercelPreviewDeploy.outputs.url }}
+ run: npx playwright test
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
diff --git a/.gitignore b/.gitignore
index 243dca9..ac6381b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,10 @@
# testing
/coverage
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
# next.js
/.next/
diff --git a/README.md b/README.md
index 9339fa8..2c5e6bf 100644
--- a/README.md
+++ b/README.md
@@ -30,10 +30,16 @@ npm run fix
## Testing
-Tests are built with [Vitest](https://vitest.dev/).
+Unit Tests are built with [Vitest](https://vitest.dev/).
```bash
-npm run test
+npm run test:unit
+```
+
+E2e Tests are built with [Playwright](https://playwright.dev/).
+
+```bash
+npm run test:e2e
```
## Development configuration
diff --git a/next-env.d.ts b/next-env.d.ts
index 4f11a03..40c3d68 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -2,4 +2,4 @@
///
// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/package-lock.json b/package-lock.json
index d5f266d..97d49e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"react-i18next": "^15.0.1"
},
"devDependencies": {
+ "@playwright/test": "^1.47.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.1",
"@types/exenv": "^1.2.2",
@@ -34,6 +35,7 @@
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "4.3.1",
+ "dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.6",
"eslint-plugin-import": "^2.29.1",
@@ -1523,6 +1525,22 @@
"node": ">=14"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.0.tgz",
+ "integrity": "sha512-SgAdlSwYVpToI4e/IH19IHHWvoijAYH5hu2MWSXptRypLSnzj51PcGD+rsOXFayde4P9ZLi+loXVwArg6IUkCA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.47.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
@@ -3509,6 +3527,45 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
+ "node_modules/dotenv": {
+ "version": "16.4.5",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
+ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dotenv-cli": {
+ "version": "7.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.4.2.tgz",
+ "integrity": "sha512-SbUj8l61zIbzyhIbg0FwPJq6+wjbzdn9oEtozQpZ6kW2ihCcapKVZj49oCT3oPM+mgQm+itgvUQcG5szxVrZTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "dotenv": "^16.3.0",
+ "dotenv-expand": "^10.0.0",
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "dotenv": "cli.js"
+ }
+ },
+ "node_modules/dotenv-expand": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
+ "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -7009,6 +7066,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.0.tgz",
+ "integrity": "sha512-jOWiRq2pdNAX/mwLiwFYnPHpEZ4rM+fRSQpRHwEwZlP2PUANvL3+aJOF/bvISMhFD30rqMxUB4RJx9aQbfh4Ww==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.47.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.47.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.0.tgz",
+ "integrity": "sha512-1DyHT8OqkcfCkYUD9zzUTfg7EfTd+6a8MkD/NWOvjo0u/SCNd5YmY/lJwFvUZOxJbWNds+ei7ic2+R/cRz/PDg==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
diff --git a/package.json b/package.json
index f1d0a7e..c625b29 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,8 @@
"fix": "npm run fix:js && npm run fix:css",
"fix:js": "next lint --fix",
"fix:css": "stylelint \"**/*.css\" --fix",
- "test": "vitest"
+ "test:unit": "vitest",
+ "test:e2e": "playwright test"
},
"dependencies": {
"accept-language": "^3.0.20",
@@ -31,6 +32,7 @@
"react-i18next": "^15.0.1"
},
"devDependencies": {
+ "@playwright/test": "^1.47.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.1",
"@types/exenv": "^1.2.2",
@@ -41,6 +43,7 @@
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "4.3.1",
+ "dotenv-cli": "^7.4.2",
"eslint": "^8.56.0",
"eslint-config-next": "^14.2.6",
"eslint-plugin-import": "^2.29.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..93e70c8
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,43 @@
+import path from 'node:path'
+
+import { defineConfig, devices } from '@playwright/test'
+import dotenv from 'dotenv'
+
+
+dotenv.config({ path: path.resolve(__dirname, '.env') })
+
+const environment = process.env.NEXT_PUBLIC_NODE_ENV || 'development'
+
+export default defineConfig({
+ forbidOnly: environment !== 'development',
+ fullyParallel: true,
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+ {
+ name: 'Mobile Chrome',
+ use: { ...devices['Pixel 5'] },
+ },
+ {
+ name: 'Mobile Safari',
+ use: { ...devices['iPhone 12'] },
+ },
+ ],
+ reporter: 'html',
+ retries: environment !== 'development' ? 2 : 0,
+ testDir: './src/',
+ testMatch: '**/*.e2e.ts',
+ use: {
+ baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
+ screenshot: 'only-on-failure',
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+ },
+ workers: environment !== 'development' ? 1 : undefined,
+})
diff --git a/src/app/[locale]/__e2e__/fixtures/index.ts b/src/app/[locale]/__e2e__/fixtures/index.ts
new file mode 100644
index 0000000..8abca46
--- /dev/null
+++ b/src/app/[locale]/__e2e__/fixtures/index.ts
@@ -0,0 +1,2 @@
+export * from './page/AppPage'
+export * from './test/testAppPage'
diff --git a/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts b/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts
new file mode 100644
index 0000000..9205797
--- /dev/null
+++ b/src/app/[locale]/__e2e__/fixtures/page/AppPage.ts
@@ -0,0 +1,8 @@
+import { BasePage } from 'src/tests/playwright/fixtures'
+
+
+export class AppPage extends BasePage {
+ async goto(): Promise {
+ await this.page.goto('/')
+ }
+}
diff --git a/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts b/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts
new file mode 100644
index 0000000..c0e6269
--- /dev/null
+++ b/src/app/[locale]/__e2e__/fixtures/test/testAppPage.ts
@@ -0,0 +1,11 @@
+import test from '@playwright/test'
+
+import { AppPage } from '../page/AppPage'
+
+
+export const testAppPage = test.extend<{ appPage: AppPage }>({
+ async appPage({ page }, use) {
+ const appPage = new AppPage(page)
+ await use(appPage)
+ }
+})
diff --git a/src/app/[locale]/app.e2e.ts b/src/app/[locale]/app.e2e.ts
new file mode 100644
index 0000000..02f7ba2
--- /dev/null
+++ b/src/app/[locale]/app.e2e.ts
@@ -0,0 +1,32 @@
+import { expect } from '@playwright/test'
+
+import { testAppPage as test } from './__e2e__/fixtures'
+
+
+test.beforeEach(async ({ appPage }) => {
+ await appPage.goto()
+})
+
+test.describe('src / app / [locale] > app', () => {
+ test('check header existance', async ({ appPage }) => {
+ expect(appPage.header.getElement()).toBeDefined()
+ expect(appPage.header.getElement()).toBeInViewport()
+
+ expect(appPage.header.getLogo()).toBeDefined()
+ expect(appPage.header.getLogo()).toBeInViewport()
+ })
+
+ test('check navigation menu', async ({ appPage }) => {
+ expect(appPage.navigation.getElement()).toBeInViewport()
+
+ expect(appPage.navigation.getMenu('')).toBeDefined()
+ expect(appPage.navigation.getMenu('places')).toBeDefined()
+ })
+
+ test('check locales menu', async ({ appPage }) => {
+ expect(appPage.locales.getElement()).toBeInViewport()
+
+ expect(appPage.locales.getLanguage('en')).toBeDefined()
+ expect(appPage.locales.getLanguage('it')).toBeDefined()
+ })
+})
diff --git a/src/tests/playwright/fixtures/index.ts b/src/tests/playwright/fixtures/index.ts
new file mode 100644
index 0000000..67b781c
--- /dev/null
+++ b/src/tests/playwright/fixtures/index.ts
@@ -0,0 +1 @@
+export * from './page/BasePage'
diff --git a/src/tests/playwright/fixtures/page/BasePage.ts b/src/tests/playwright/fixtures/page/BasePage.ts
new file mode 100644
index 0000000..3bdea89
--- /dev/null
+++ b/src/tests/playwright/fixtures/page/BasePage.ts
@@ -0,0 +1,53 @@
+import type { Locator, Page } from 'playwright/test'
+
+
+export class BasePage {
+ constructor(
+ public readonly page: Page,
+ public readonly header: Header = new Header(page),
+ public readonly navigation: Navigation = new Navigation(page),
+ public readonly locales: Locales = new Locales(page),
+ ) {}
+}
+
+class Header {
+ constructor(
+ private readonly page: Page,
+ ) {}
+
+ getElement(): Locator {
+ return this.page.locator('header')
+ }
+
+ getLogo(): Locator {
+ return this.getElement().getByRole('navigation', { name: /main logo/i })
+ }
+}
+
+class Navigation {
+ constructor(
+ private readonly page: Page,
+ ) {}
+
+ getElement(): Locator {
+ return this.page.getByRole('menubar', { name: /menu/i })
+ }
+
+ getMenu(href: string) {
+ return this.getElement().locator(`a[href*="/${href}"]`)
+ }
+}
+
+class Locales {
+ constructor(
+ private readonly page: Page,
+ ) {}
+
+ getElement(): Locator {
+ return this.page.getByRole('menubar', { name: /language navigation/i })
+ }
+
+ getLanguage(language: string) {
+ return this.getElement().locator(`a[href*="/${language}"]`)
+ }
+}