認証機能のテストを追加

(149d) 更新


プログラミング/svelte

いまさらテストを追加

サーバーの起動とテストの実行を切り離した方が使いやすそう?

サーバーの起動

デバッグサーバーでテストするならこう

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

なかなかに落とし穴も多そうな雰囲気だ?

長大なテストになった

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(-)
$

Counter: 225 (from 2010/06/03), today: 4, yesterday: 1