認証機能のテストを追加 の変更点
更新- 追加された行はこの色です。
- 削除された行はこの色です。
- プログラミング/svelte/認証機能のテストを追加 へ行く。
- プログラミング/svelte/認証機能のテストを追加 の差分を削除
[[プログラミング/svelte]]
* いまさらテストを追加 [#iac1c315]
サーバーの起動とテストの実行を切り離した方が使いやすそう?
#contents
** サーバーの起動 [#r621d0b1]
デバッグサーバーでテストするならこう
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",
こうかな?
** テストの実行 [#o5d2e777]
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(-)
** テストを書く [#i8e7b89e]
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( にするとスキップできる
** 長大なテストになった [#ae5325df]
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 で指定するようにした [#fef4f485]
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: 712 (from 2010/06/03),
today: 3,
yesterday: 4