認証機能のテストを追加 の履歴(No.1)
更新- 履歴一覧
- 差分 を表示
- 現在との差分 を表示
- ソース を表示
- プログラミング/svelte/認証機能のテストを追加 へ行く。
- 1
いまさらテストを追加†
サーバーの起動とテストの実行を切り離した方が使いやすそう?
サーバーの起動†
デバッグサーバーでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate && pnpm dev --port 4173'
ビルド済みでテストするならこう
LANG: console $ DATABASE_URL="file:test.db" bash -c 'prisma migrate && pnpm build && pnpm preview --port 4173'
これを 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 && cross-env DATABASE_URL='file:test.db' vite dev --port 4173", + "test-preview": "cross-env DATABASE_URL='file:test.db' prisma migrate && 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.authUser.deleteMany();
expect(await db.emailVerification.count()).toBe(0);
expect(await db.authUser.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('/signup のメールアドレスの検証', async ({ page }, info) => {
await page.goto('/signup', {waitUntil: 'load'});
await page.locator('input[name="email"]').fill('');
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
await page.locator('input[name="email"]').fill('abcd@');
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
await page.locator('input[name="email"]').fill('@abcc');
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).toContainText('メールアドレスが不正です');
await screenshot(page, info);
});
test('メールアドレスを入力する /signup', async ({ page }, info) => {
await page.goto('/signup', {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('/signup', {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.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).toContainText('先ほどメールを送信しましたのでしばらく経ってから試してください');
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 expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="name"]+.invalid')).toContainText('文字');
await expect(page.locator('input[name="name"]')).not.toHaveProperty('disabled');
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 expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
await screenshot(page, info);
await expect(page.locator('input[name="name"]+.invalid')).not.toBeVisible();
await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
await page.fill('input[name="password"]', 'aaaaaaaa');
await page.click('form button');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="password"]+.invalid')).toContainText('文字');
await page.fill('input[name="password"]', 'Aa1aaaaa');
await page.click('form button');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="password"]+.invalid')).not.toBeVisible();
await expect(page.locator('input[name="confirm"]+.invalid')).toContainText('一致しません');
await page.fill('input[name="confirm"]', 'Aa1aaaaa');
await screenshot(page, info);
await page.click('form button');
await expect(page.locator('form button')).not.toHaveProperty('disabled');
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('/logout', {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('/login', {waitUntil: 'load'});
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill(password);
await page.locator('input[name="password"]').press('Enter');
await page.waitForNavigation({url: 'http://localhost:4173/', waitUntil: 'load'});
await expect(page.locator('.toaster .message').nth(0)).toHaveText('ログインしました');
}
test('ログインしていないのにログアウトする', async ({ page }, info) => {
await page.goto('/logout', {waitUntil: 'load'});
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 page.goto('/login', {waitUntil: 'load'});
await screenshot(page, info);
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="email"]+.invalid')).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 expect(page.locator('input[name="email"]')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="password"]+.invalid')).toContainText('入力して下さい');
await page.locator('input[name="password"]').fill('Aa1aaaaa');
await screenshot(page, info);
await page.locator('input[name="password"]').press('Enter');
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('/change-profile', {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('/change-profile', {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('/change-profile', {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 expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="password"]+.invalid')).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 expect(page.locator('input[name="password"]')).not.toHaveProperty('disabled');
await expect(page.locator('input[name="confirm"]+.invalid')).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('/change-profile', {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('/change-email', {waitUntil: 'load'});
await screenshot(page, info);
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('input[name="email"]+.invalid')).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('/change-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('/reset-password', {waitUntil: 'load'});
await screenshot(page, info);
await page.locator('input[name="email"]').press('Enter');
await expect(page.locator('input[name="email"]+.invalid')).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"]+.invalid')).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"]+.invalid')).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)
Counter: 1365 (from 2010/06/03),
today: 4,
yesterday: 0