認証機能のテストを追加
いまさらテストを追加†
サーバーの起動とテストの実行を切り離した方が使いやすそう?
サーバーの起動†
デバッグサーバーでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate deploy && pnpm dev --port 4173'
ビルド済みでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate deploy && pnpm build && pnpm preview --port 4173'
"prisma migrate dev" が prisma/schema.ts の変更があれば新しい migration を作ってそれをデータベースに適用するのに対して、"prisma migrate deploy" はすでにある prisma/migrations だけを見てデータベースを最新にする。テスト時にやりたいのは migration deploy だ。
これを package.json に入れる方法が良く分からない?
https://qiita.com/riversun/items/d45b26f4a7aad6e51b69 によると、
LANG: console $ pnpm add -D cross-env
しておいて、
package.json
* "test": "pnpm test:integration && pnpm test:unit", + "test-dev": "cross-env DATABASE_URL='file:test.db' prisma migrate deploy && cross-env DATABASE_URL='file:test.db' vite dev --port 4173", + "test-preview": "cross-env DATABASE_URL='file:test.db' prisma migrate deploy && cross-env DATABASE_URL='file:test.db' vite build && cross-env DATABASE_URL='file:test.db' vite preview --port 4173",
こうかな?
テストの実行†
playwright.config.ts
LANG: ts import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { webServer: { * command: '', // 'npm run build && npm run preview', port: 4173, reuseExistingServer: !process.env.CI, }, testDir: 'tests', testMatch: /(.+\.)?(test|spec)\.[jt]s/, }; export default config;
および
package.json
+ "test:integration": "cross-env DATABASE_URL='file:test.db' playwright test", + "test:unit": "cross-env DATABASE_URL='file:test.db' vitest"
の設定で
LANG: console $ pnpm test-dev
あるいは
LANG: console $ pnpm test-preview
でサーバーを起動して置き、別のターミナルから
LANG: console $ pnpm test:integration
とすればいい。
・・・と思ったら初期化が足りないというエラーが出た。
LANG: console $ pnpm test:integration > authtest@0.0.1 test:integration C:\Users\osamu\Desktop\svelte\authtest > cross-env DATABASE_URL='file:test.db' playwright test Running 1 test using 1 worker ✘ 1 test.ts:3:1 › index page has expected h1 (26ms) 1) test.ts:3:1 › index page has expected h1 ────────────────────────────────────────────────────── Error: browserType.launch: Executable doesn't exist at C:\Users\osamu\AppData\Local\ms-playwright\chromium-1091\chrome-win\chrome.exe ╔═════════════════════════════════════════════════════════════════════════╗ ║ Looks like Playwright Test or Playwright was just installed or updated. ║ ║ Please run the following command to download new browsers: ║ ║ ║ ║ pnpm exec playwright install ║ ║ ║ ║ <3 Playwright Team ║ ╚═════════════════════════════════════════════════════════════════════════╝ 1 failed test.ts:3:1 › index page has expected h1 ─────────────────────────────────────────────────────── ELIFECYCLE Command failed with exit code 1. $ pnpm exec playwright install Downloading Chromium 120.0.6099.28 (playwright build v1091) from https://playwright.azureedge.net/builds/chromium/1091/chromium-win64.zip 122 Mb [====================] 100% 0.0s Chromium 120.0.6099.28 (playwright build v1091) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\chromium-1091 Downloading Firefox 119.0 (playwright build v1429) from https://playwright.azureedge.net/builds/firefox/1429/firefox-win64.zip 80.5 Mb [====================] 100% 0.0s Firefox 119.0 (playwright build v1429) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\firefox-1429 Downloading Webkit 17.4 (playwright build v1944) from https://playwright.azureedge.net/builds/webkit/1944/webkit-win64.zip 46.4 Mb [====================] 100% 0.0s Webkit 17.4 (playwright build v1944) downloaded to C:\Users\osamu\AppData\Local\ms-playwright\webkit-1944
これで、
LANG: console $ pnpm test:integration > authtest@0.0.1 test:integration C:\Users\osamu\Desktop\svelte\authtest > cross-env DATABASE_URL='file:test.db' playwright test Running 1 test using 1 worker ✓ 1 test.ts:3:1 › index page has expected h1 (404ms) 1 passed (2.5s)
テストが走るようになった。
LANG: console $ git add . && git commit -m "テスト環境を整えた" package.json 98ms (unchanged) 🌼 daisyUI 4.4.14 ├─ ✔︎ 1 theme added https://daisyui.com/docs/themes ╰─ ❤︎ Support daisyUI project: https://opencollective.com/daisyui playwright.config.ts 1636ms [master 169df63] テスト環境を整えた 3 files changed, 19 insertions(+), 4 deletions(-)
テストを書く†
https://playwright.dev/docs/api/class-test
例えばこんな風に書ける。
LANG: ts test('/signup へフォームを送信', async ({ page }) => { await page.goto('/signup', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible(); });
でもこれを、
LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('form button').click();
とすると email フィールドへの入力が送信データに反映されないケースがあったり(svelte による reactivity が発揮される前にフォームが送信されてしまうため?)、
なぜか fill を2回繰り返さないと書き込み自体を行えないケースがあったり、
LANG: ts await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); // 念のためもう一度 orz
なかなかに落とし穴も多そうな雰囲気だ?
- locator と WaitForSelector, $, $$ の違い https://qiita.com/ko-he-8/items/85116e1d99ed4b176657
- npm playwright codegen で操作手順の記録ができる https://playwright.dev/docs/codegen-intro
- 以前 --ui オプションで GUI が立ち上がるのを確認した記録があるのだけれど、このプロジェクトにインストールされているバージョンだとそんなオプションないと言われる?
- https://www.summerbud.org/dev-notes/playwright-tips-that-will-make-your-life-easier
- .fill などは readonly や disable の間はそうじゃなくなるまで待つらしい
- race condition を避けるために Promise.all を使う
- DEBUG=pw:api pnpm test:integration で verbose な出力が得られる
- テストは test( を test.skip( にするとスキップできる
長大なテストになった†
tests/auth.test.ts
LANG: ts import { expect, test, type Page, type TestInfo } from '@playwright/test'; import type { SendMailOptions } from "nodemailer"; import * as fs from "fs"; import { db } from '../src/lib/server/db.js'; test.setTimeout(5000); let testId = 0; let screenshotId = 0; let screenshotTitle = ""; async function screenshot(page: Page, info: TestInfo) { if(screenshotTitle != info.title) { testId++; screenshotTitle = info.title; screenshotId = 0; } await page.screenshot({path: `test-results/ss${testId.toString().padStart(4, '0')}-${info.title.replaceAll("/","_")}${++screenshotId}.png`}) } test('データベースをクリアする', async () => { await db.emailVerification.deleteMany(); await db.user.deleteMany(); expect(await db.emailVerification.count()).toBe(0); expect(await db.user.count()).toBe(0); }); test('インデックスページを表示できる', async ({ page }, info) => { await page.goto('/', {waitUntil: 'load'}); await expect(page.getByRole('heading', { name: 'Welcome to Svelte' })).toBeVisible(); await screenshot(page, info); }); test('/account/new のメールアドレスの検証', async ({ page }, info) => { await page.goto('/account/new', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill(''); await page.locator('input[name="email"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('メールアドレスが不正です'); await page.locator('input[name="email"]').fill('abcd@'); await page.locator('input[name="email"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('メールアドレスが不正です'); await page.locator('input[name="email"]').fill('@abcc'); await page.locator('input[name="email"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('メールアドレスが不正です'); await screenshot(page, info); }); test('メールアドレスを入力する /account/new', async ({ page }, info) => { await page.goto('/account/new', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('form button')).not.toBeVisible(); await page.waitForLoadState('load'); await expect(page.url()).toBe('http://localhost:4173/'); await expect(page.locator('.toaster .message')).toHaveText('メールを送信しました') await page.waitForTimeout(200); await screenshot(page, info); }); test('連続して送ろうとするとエラーになる', async ({ page }, info) => { await page.goto('/account/new', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('.toaster .message').nth(0)).toHaveText('先ほどメールを送信しましたのでしばらく経ってから試してください') await screenshot(page, info); }); test('サインアップを続ける', async ({ page }, info) => { const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await page.click('form button'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="name"]+label>.text-error')).toContainText('文字'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="name"]').fill('First Family'); // 1回だと失敗することがある??? await page.locator('input[name="name"]').press('Enter'); await screenshot(page, info); await page.locator('form button').isEnabled(); await screenshot(page, info); await expect(page.locator('input[name="name"]+label>.text-error')).not.toBeVisible(); await expect(page.locator('input[name="password"]+label>.text-error')).toContainText('文字'); await page.fill('input[name="password"]', 'aaaaaaaa'); await page.click('form button'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="password"]+label>.text-error')).toContainText('文字'); await page.fill('input[name="password"]', 'Aa1aaaaa'); await page.click('form button'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="password"]+label>.text-error')).not.toBeVisible(); await expect(page.locator('input[name="confirm"]+label>.text-error')).toContainText('一致しません'); await page.fill('input[name="confirm"]', 'Aa1aaaaa'); await screenshot(page, info); await page.click('form button'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('サインアップ&ログインしました') await page.waitForTimeout(200); await screenshot(page, info); // ログアウトする await logout(page, info); await page.waitForTimeout(200); await screenshot(page, info); }); async function logout(page: Page, info: TestInfo) { Promise.all([ page.goto('/session/delete', {waitUntil: 'load'}), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログアウトしました') await page.waitForTimeout(200); await screenshot(page, info); } async function login(page: Page, email='test@example.com', password='Aa1aaaaa') { await page.goto('/session/new', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill(email); await page.locator('input[name="password"]').fill(password); await Promise.all([ page.locator('input[name="password"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました'); } test('ログインしていないのにログアウトする', async ({ page }, info) => { await page.goto('/session/delete', {waitUntil: 'load'}); await page.waitForNavigation({url: 'http://localhost:4173/session/new', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインユーザーのみアクセスできます') await page.waitForTimeout(200); await screenshot(page, info); }); test('ログインする', async ({ page }, info) => { // ログインする await page.goto('/session/new', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('入力して下さい'); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="password"]+label>.text-error')).toContainText('入力して下さい'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="password"]').press('Enter'); await screenshot(page, info); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました'); await page.waitForTimeout(200); await screenshot(page, info); }); test('名前やパスワードを変更する', async ({ page }, info)=> { // ログイン await login(page); // 名前・パスワードを変更する(実際にはしない) await page.goto('/account/edit', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="name"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更されませんでした'); await page.waitForTimeout(200); await screenshot(page, info); // 名前を変更する await page.goto('/account/edit', {waitUntil: 'load'}); await page.locator('input[name="name"]').fill('First Middle Family'); await page.locator('input[name="name"]').fill('First Middle Family'); await page.locator('input[name="name"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('名前が変更されました'); await page.waitForTimeout(200); await screenshot(page, info); // パスワードを変更する await page.goto('/account/edit', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="password"]').fill('Aa1aaaa'); await page.locator('input[name="password"]').fill('Aa1aaaa'); await page.locator('input[name="password"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="password"]+label>.text-error')).toContainText('文字'); await page.locator('input[name="password"]').fill('Aa1aaaab'); await page.locator('input[name="password"]').fill('Aa1aaaab'); await page.locator('input[name="password"]').press('Enter'); await page.locator('form button').isEnabled(); await expect(page.locator('input[name="confirm"]+label>.text-error')).toContainText('一致しません'); await page.locator('input[name="confirm"]').fill('Aa1aaaab'); await page.locator('input[name="confirm"]').fill('Aa1aaaab'); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードが変更されました'); await page.waitForTimeout(200); await screenshot(page, info); // 名前とパスワードを変更する await page.goto('/account/edit', {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Middle Family'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="name"]').fill('First Family'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('名前とパスワードが変更されました'); await page.waitForTimeout(200); await screenshot(page, info); await logout(page, info); }); test('メールアドレスを変更する', async ({ page }, info)=> { // ログイン await login(page); await page.goto('/account/email', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('不正です'); await page.locator('input[name="email"]').fill('changed@example.com'); await page.locator('input[name="email"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); await page.waitForTimeout(200); await screenshot(page, info); const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Family'); await expect(page.locator('input[name="email-old"]')).toHaveValue('test@example.com'); await expect(page.locator('input[name="email-new"]')).toHaveValue('changed@example.com'); Promise.all([ page.locator('form button').click(), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました'); await page.waitForTimeout(200); await screenshot(page, info); // ログアウト await logout(page, info); }); test('新しいメールアドレスでログインしてから元に戻す', async ({ page }, info)=> { await login(page, 'changed@example.com'); await page.goto('/account/email', {waitUntil: 'load'}); await page.locator('input[name="email"]').fill('test@example.com'); await page.locator('input[name="email"]').fill('test@example.com'); await Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); const mailOptions2 = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions2.text).toContain('http'); await page.goto((mailOptions2.text as string).split(/\n/)[1], {waitUntil: 'load'}); await page.locator('form button').click(); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('変更しました'); await logout(page, info); }); test('パスワードのリセットを行う', async ({ page }, info) => { await page.goto('/account/reset', {waitUntil: 'load'}); await screenshot(page, info); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('不正です'); await page.locator('input[name="email"]').fill('notfound@example.com'); await page.locator('input[name="email"]').press('Enter'); await expect(page.locator('input[name="email"]+label>.text-error')).toContainText('登録されていません'); await page.locator('input[name="email"]').fill('test@example.com'); Promise.all([ page.locator('input[name="email"]').press('Enter'), page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}) ]); await expect(page.locator('.toaster .message').nth(0)).toContainText('送信しました'); await page.waitForTimeout(200); await screenshot(page, info); const mailOptions = JSON.parse(fs.readFileSync('test-results/mailOptions.txt', 'utf-8')) as SendMailOptions; await expect(mailOptions.text).toContain('http'); await page.goto((mailOptions.text as string).split(/\n/)[1], {waitUntil: 'load'}); await screenshot(page, info); await expect(page.locator('input[name="name"]')).toHaveValue('First Family'); await expect(page.locator('input[name="email"]')).toHaveValue('test@example.com'); await page.locator('input[name="password"]').press('Enter'); await expect(page.locator('input[name="password"]+label>.text-error')).toContainText('文字'); await page.locator('input[name="password"]').fill('Aa1aaaaa'); await page.locator('input[name="confirm"]').fill('Aa1aaaaa'); await screenshot(page, info); await page.locator('input[name="confirm"]').press('Enter'); await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'}); await expect(page.locator('.toaster .message').nth(0)).toContainText('パスワードを変更してログインしました'); await page.waitForTimeout(200); await screenshot(page, info); });
LANG: console $ pnpm test:integration > authtest@0.0.1 test:integration C:\Users\osamu\Desktop\svelte\authtest > cross-env DATABASE_URL='file:test.db'; playwright test Running 12 tests using 1 worker ✓ 1 auth.test.ts:13:1 › データベースをクリアする (44ms) ✓ 2 auth.test.ts:21:1 › インデックスページを表示できる (351ms) ✓ 3 auth.test.ts:27:1 › /signup のメールアドレスの検証 (580ms) ✓ 4 auth.test.ts:48:1 › メールアドレスを入力する /signup (749ms) ✓ 5 auth.test.ts:64:1 › 連続して送ろうとするとエラーになる (509ms) ✓ 6 auth.test.ts:75:1 › サインアップを続ける (2s) ✓ 7 auth.test.ts:146:1 › ログインしていないのにログアウトする (569ms) ✓ 8 auth.test.ts:155:1 › ログインする (1s) ✓ 9 auth.test.ts:181:1 › 名前やパスワードを変更する (4s) ✓ 10 auth.test.ts:256:1 › メールアドレスを変更する (2s) ✓ 11 auth.test.ts:297:1 › 新しいメールアドレスでログインしてから元に戻す (1s) ✓ 12 auth.test.ts:321:1 › パスワードのリセットを行う (2s) Slow test file: auth.test.ts (16s) Consider splitting slow test files to speed up parallel execution 12 passed (17s)
LANG: console $ git add . && git commit -m "認証部分のテストを追加"
Action を satisfies で指定するようにした†
src/routes/+page.server.ts
LANG: ts - export const load: PageServerLoad = async ({ locals }) => { - return { user: locals.session?.user }; - }; + export const load = (async ({ locals }) => { + return { user: locals.session?.user }; + }) satisfies PageServerLoad;
こういうやつ。
っていうか、上のやつは Action じゃなく PageServerLoad がおかしなところについていた。
LANG: console $ git add . && git commit -m "Action を satisfies で指定するようにした" 🌼 daisyUI 4.4.14 ├─ ✔︎ 1 theme added https://daisyui.com/docs/themes ╰─ ★ Star daisyUI on GitHub https://github.com/saadeghi/daisyui src/routes/+page.server.ts 1646ms (unchanged) src/routes/account/(login)/edit/+page.server.ts 45ms (unchanged) src/routes/account/(logout)/new/[token]/+page.server.ts 34ms (unchanged) src/routes/account/[purpose=emailVerificationPurpose]/+page.server.ts 34ms (unchanged) src/routes/session/(logout)/new/+page.server.ts 16ms (unchanged) [master 2ea29a3] Action を satisfies で指定するようにした 5 files changed, 10 insertions(+), 10 deletions(-)
$