diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a05e84544006c8f0507f219f5f3da3fbf8b6a177..8fafbc270f4a2e29bbdfd4d6c906374e62bf57d3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -11,6 +11,9 @@ ci:
     # Without apt update, apt install fails.
     - apt update --yes
 
+    # Install playwright dependencies.
+    - apt install --yes libasound2 libatk-bridge2.0-0 libatspi2.0-0 libcups2 libdbus-1-3 libdrm2 libnss3 libgbm1 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2
+
     # Install ssh-agent if not already installed, it is required by Docker.
     - "which ssh-agent || apt install -y openssh-client"
     # Run ssh-agent (inside the build environment)
@@ -24,6 +27,8 @@ ci:
     # Needed when Node version changes:
     - npm rebuild
 
+    - npx playwright install chromium
+
     # Compile gitlab-ci TypeScript script.
     - npx tsc --declaration --project gitlab-ci/tsconfig.json
   script:
diff --git a/gitlab-ci/src/gitlab-ci.ts b/gitlab-ci/src/gitlab-ci.ts
index 66b1df7212cdf6e300e4e75dea0ea52bb782f663..3b03cd270fee7145d498f157e26df8fb2cdaf65c 100644
--- a/gitlab-ci/src/gitlab-ci.ts
+++ b/gitlab-ci/src/gitlab-ci.ts
@@ -201,7 +201,7 @@ async function main() {
         // Test project with the current dependencies (and latest version of @openfisca/france-json).
         await $`ln -s example.env .env`
         await $`npm run build`
-        // TODO: Add tests.
+        await $`npm test`
 
         await $`git add .`
         if ((await $`git diff --quiet --staged`.exitCode) !== 0) {
@@ -248,7 +248,7 @@ async function main() {
           // Test project with the current dependencies (and latest @openfisca/france-json).
           await $`ln -s example.env .env`
           await $`npm run build`
-          // TODO: Add tests.
+          await $`npm test`
 
           if (CI_COMMIT_BRANCH === CI_DEFAULT_BRANCH) {
             // A merge request has been merged into master (ie content of project has been changed).
diff --git a/package-lock.json b/package-lock.json
index b67da7672e0f91c94853c21fd0a4b81793f768f9..09d1dc08d2df48b22888cce0c29a6898461e47dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
         "@iconify/svelte": "^2.1.2",
         "@openfisca/france-json": "^0.1.73",
         "@openfisca/json-model": "^1.1.13",
+        "@playwright/test": "^1.22.2",
         "@rgossiaux/svelte-headlessui": "^1.0.0-beta.12",
         "@sveltejs/adapter-node": "^1.0.0-next.78",
         "@sveltejs/kit": "^1.0.0-next.350",
@@ -2035,6 +2036,22 @@
         "node": ">=16"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.22.2.tgz",
+      "integrity": "sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "playwright-core": "1.22.2"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/@rgossiaux/svelte-headlessui": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@rgossiaux/svelte-headlessui/-/svelte-headlessui-1.0.2.tgz",
@@ -5028,6 +5045,18 @@
         "node": ">=6"
       }
     },
+    "node_modules/playwright-core": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.22.2.tgz",
+      "integrity": "sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==",
+      "dev": true,
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.4.14",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
@@ -7647,6 +7676,16 @@
         "regenerator-runtime": "^0.13.7"
       }
     },
+    "@playwright/test": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.22.2.tgz",
+      "integrity": "sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*",
+        "playwright-core": "1.22.2"
+      }
+    },
     "@rgossiaux/svelte-headlessui": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/@rgossiaux/svelte-headlessui/-/svelte-headlessui-1.0.2.tgz",
@@ -9753,6 +9792,12 @@
         "find-up": "^3.0.0"
       }
     },
+    "playwright-core": {
+      "version": "1.22.2",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.22.2.tgz",
+      "integrity": "sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==",
+      "dev": true
+    },
     "postcss": {
       "version": "8.4.14",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz",
diff --git a/package.json b/package.json
index 61fe164462cf63dee60c7c2c57b49555d7f31432..5b08561c783a0f6e368d5cce0228bdd9a7d2e4e2 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
     "format": "prettier --write --plugin-search-dir=. .",
     "lint": "prettier --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
     "prepare": "svelte-kit sync",
-    "preview": "svelte-kit preview"
+    "preview": "svelte-kit preview",
+    "test": "playwright test"
   },
   "dependencies": {
     "sk-auth": "^0.4.0",
@@ -22,6 +23,7 @@
     "@iconify/svelte": "^2.1.2",
     "@openfisca/france-json": "^0.1.73",
     "@openfisca/json-model": "^1.1.13",
+    "@playwright/test": "^1.22.2",
     "@rgossiaux/svelte-headlessui": "^1.0.0-beta.12",
     "@sveltejs/adapter-node": "^1.0.0-next.78",
     "@sveltejs/kit": "^1.0.0-next.350",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000000000000000000000000000000000000..27ca5a0ec522a32cfe426317a347092503d3a6d6
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,10 @@
+import type { PlaywrightTestConfig } from "@playwright/test"
+
+const config: PlaywrightTestConfig = {
+  webServer: {
+    command: "npm run build && npm run preview",
+    port: 3000,
+  },
+}
+
+export default config
diff --git a/tests/test.ts b/tests/test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4356558f2ac8a06f8a8c32075c63ed2ca2afcb89
--- /dev/null
+++ b/tests/test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from "@playwright/test"
+
+test("index page has expected h1", async ({ page }) => {
+  await page.goto("/")
+  expect(await page.textContent("h1")).toContain("Modifier la loi")
+})