Author SHA1 Message Date
yuta 88730dbec9 Refactor E2E tests with page objects 2026-07-26 13:41:57 +09:00
8 changed files with 238 additions and 95 deletions
+24
View File
@@ -0,0 +1,24 @@
import { test as base } from '@playwright/test'
import { HomePage } from './models/HomePage'
import { ListsPage } from './models/ListsPage'
import { SearchPage } from './models/SearchPage'
import { ThreadPage } from './models/ThreadPage'
import { UserPage } from './models/UserPage'
type Fixtures = {
homePage: HomePage
listsPage: ListsPage
searchPage: SearchPage
threadPage: ThreadPage
userPage: UserPage
}
export const test = base.extend<Fixtures>({
homePage: ({ page }, use) => use(new HomePage(page)),
listsPage: ({ page }, use) => use(new ListsPage(page)),
searchPage: ({ page }, use) => use(new SearchPage(page)),
threadPage: ({ page }, use) => use(new ThreadPage(page)),
userPage: ({ page }, use) => use(new UserPage(page)),
})
export { expect } from '@playwright/test'
+49
View File
@@ -0,0 +1,49 @@
import { expect, type Locator, type Page } from '@playwright/test'
export class Base {
readonly ambientLinksLocator: Locator
readonly articlesLocator: Locator
readonly continuationErrorLocator: Locator
readonly endOfFeedLocator: Locator
readonly loadingRailLocator: Locator
readonly mutationButtonsLocator: Locator
readonly mutationLinksLocator: Locator
constructor(readonly page: Page) {
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
const mutationControlNames =
/投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
this.ambientLinksLocator = page.getByRole('link', {
name: ambientLinkNames,
})
this.articlesLocator = page.locator('article')
this.continuationErrorLocator = page
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
this.endOfFeedLocator = page.getByText('これ以上の投稿はありません。')
this.loadingRailLocator = page.locator('.loading-rail')
this.mutationButtonsLocator = page.getByRole('button', {
name: mutationControlNames,
})
this.mutationLinksLocator = page.getByRole('link', {
name: mutationControlNames,
})
}
protected async navigateTo(path: string) {
await this.page.goto(path)
await this.page.waitForLoadState('networkidle')
}
async tabTo(target: Locator) {
await this.page.keyboard.press('Tab')
await expect(target).toBeFocused()
}
async retryContinuation() {
await this.continuationErrorLocator
.getByRole('button', { name: '再試行' })
.click()
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class HomePage extends Base {
readonly handleInputLocator: Locator
readonly submitButtonLocator: Locator
constructor(page: Page) {
super(page)
this.handleInputLocator = page.getByLabel('ハンドルまたはプロフィール URL')
this.submitButtonLocator = page.getByRole('button', { name: '表示' })
}
async goTo() {
await this.navigateTo('/')
}
async displayUser(handle: string) {
await this.handleInputLocator.fill(handle)
await this.submitButtonLocator.click()
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Base } from './Base'
export class ListsPage extends Base {
async goTo() {
await this.navigateTo('/i/lists')
}
async openList(name: RegExp | string) {
await this.page.getByRole('link', { name }).click()
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class SearchPage extends Base {
readonly followingOnlyLocator: Locator
readonly latestRankingLocator: Locator
readonly queryInputLocator: Locator
readonly searchButtonLocator: Locator
readonly topRankingLocator: Locator
constructor(page: Page) {
super(page)
this.followingOnlyLocator = page.getByLabel('フォロー中のみ')
this.latestRankingLocator = page.getByLabel('最新順')
this.queryInputLocator = page.getByLabel('検索語')
this.searchButtonLocator = page.getByRole('button', { name: '検索' })
this.topRankingLocator = page.getByLabel('人気順')
}
async goTo() {
await this.navigateTo('/search?q=&product=Latest&following=false')
}
async search(query: string) {
await this.queryInputLocator.fill(query)
await this.searchButtonLocator.click()
}
async searchTopFromFollowing(query: string) {
await this.queryInputLocator.fill(query)
await this.topRankingLocator.check()
await this.followingOnlyLocator.check()
await this.searchButtonLocator.click()
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { Locator, Page } from '@playwright/test'
import { Base } from './Base'
export class ThreadPage extends Base {
readonly headingLocator: Locator
readonly selectedPostLabelLocator: Locator
constructor(page: Page) {
super(page)
this.headingLocator = page.getByRole('heading', { name: '会話' })
this.selectedPostLabelLocator = page.getByText('表示中の投稿')
}
async goTo(handle: string, tweetId: string) {
await this.navigateTo(`/${handle}/status/${tweetId}`)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Base } from './Base'
export class UserPage extends Base {
async goTo(handle: string) {
await this.navigateTo(`/${handle}`)
}
async openPostDetails(postText: string) {
await this.page
.getByRole('article')
.filter({ hasText: postText })
.getByRole('link', { name: '詳細・スレッド' })
.click()
}
}
+65 -95
View File
@@ -1,4 +1,4 @@
import { expect, type Locator, test } from '@playwright/test' import { expect, test } from './fixtures'
let consoleErrors: string[] let consoleErrors: string[]
let pageErrors: string[] let pageErrors: string[]
@@ -17,46 +17,22 @@ test.afterEach(() => {
expect(pageErrors, 'uncaught browser errors').toEqual([]) expect(pageErrors, 'uncaught browser errors').toEqual([])
}) })
const ambientLinkNames = /ホーム|おすすめ|トレンド|通知|履歴/
const mutationControlNames = /投稿する|返信|いいね|再投稿|フォロー|ブックマーク/
async function submitSearch(
page: import('@playwright/test').Page,
query: string,
) {
await openReader(page, '/search?q=&product=Latest&following=false')
await expect(page.locator('article')).toHaveCount(0)
await page.getByLabel('検索語').fill(query)
await page.getByRole('button', { name: '検索' }).click()
}
async function openReader(page: import('@playwright/test').Page, path: string) {
await page.goto(path)
await page.waitForLoadState('networkidle')
}
async function tabTo(page: import('@playwright/test').Page, target: Locator) {
await page.keyboard.press('Tab')
await expect(target).toBeFocused()
}
test('requires intent and infinitely loads a user timeline', async ({ test('requires intent and infinitely loads a user timeline', async ({
homePage,
page, page,
userPage,
}) => { }) => {
await openReader(page, '/') await homePage.goTo()
await expect(page).toHaveURL(/\/$/) await expect(page).toHaveURL(/\/$/)
await expect(page.locator('article')).toHaveCount(0) await expect(homePage.articlesLocator).toHaveCount(0)
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount( await expect(homePage.ambientLinksLocator).toHaveCount(0)
0,
)
await page.getByLabel('ハンドルまたはプロフィール URL').fill('@yuta') await homePage.displayUser('@yuta')
await page.getByRole('button', { name: '表示' }).click()
await expect(page).toHaveURL(/\/yuta$/) await expect(page).toHaveURL(/\/yuta$/)
await expect(page.getByText('user page 1')).toBeVisible() await expect(page.getByText('user page 1')).toBeVisible()
await expect(page.getByText('user page 2')).toBeVisible() await expect(page.getByText('user page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(userPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-user.png', { await expect(page).toHaveScreenshot('mist-user.png', {
animations: 'disabled', animations: 'disabled',
fullPage: true, fullPage: true,
@@ -64,36 +40,36 @@ test('requires intent and infinitely loads a user timeline', async ({
}) })
test('selects an authenticated list and infinitely loads its timeline', async ({ test('selects an authenticated list and infinitely loads its timeline', async ({
listsPage,
page, page,
}) => { }) => {
await openReader(page, '/i/lists') await listsPage.goTo()
await expect(page.locator('article')).toHaveCount(0) await expect(listsPage.articlesLocator).toHaveCount(0)
await page.getByRole('link', { name: /My E2E List.*10 メンバー/ }).click() await listsPage.openList(/My E2E List.*10 メンバー/)
await expect(page).toHaveURL(/\/i\/lists\/1234567890$/) await expect(page).toHaveURL(/\/i\/lists\/1234567890$/)
await expect(page.getByText('list page 1')).toBeVisible() await expect(page.getByText('list page 1')).toBeVisible()
await expect(page.getByText('list page 2')).toBeVisible() await expect(page.getByText('list page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(listsPage.endOfFeedLocator).toBeVisible()
}) })
test('opens a card detail and infinitely loads its conversation', async ({ test('opens a card detail and infinitely loads its conversation', async ({
page, page,
threadPage,
userPage,
}) => { }) => {
await openReader(page, '/yuta') await userPage.goTo('yuta')
const sourceCard = page await userPage.openPostDetails('user page 1')
.getByRole('article')
.filter({ hasText: 'user page 1' })
await sourceCard.getByRole('link', { name: '詳細・スレッド' }).click()
await expect(page).toHaveURL(/\/yuta\/status\/1001$/) await expect(page).toHaveURL(/\/yuta\/status\/1001$/)
await expect(page.getByRole('heading', { name: '会話' })).toBeVisible() await expect(threadPage.headingLocator).toBeVisible()
await expect(page.getByText('表示中の投稿')).toBeVisible() await expect(threadPage.selectedPostLabelLocator).toBeVisible()
await expect(page.getByText('user page 1')).toHaveCount(1) await expect(page.getByText('user page 1')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible() await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible() await expect(page.getByText('thread page 1')).toBeVisible()
await expect(page.getByText('thread page 2')).toBeVisible() await expect(page.getByText('thread page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect(page).toHaveScreenshot('mist-thread.png', { await expect(page).toHaveScreenshot('mist-thread.png', {
animations: 'disabled', animations: 'disabled',
fullPage: true, fullPage: true,
@@ -102,96 +78,90 @@ test('opens a card detail and infinitely loads its conversation', async ({
test('resolves a reply root and retries only the failed continuation', async ({ test('resolves a reply root and retries only the failed continuation', async ({
page, page,
threadPage,
}) => { }) => {
await openReader(page, '/yuta/status/2001') await threadPage.goTo('yuta', '2001')
await expect(page.getByText('retry focal')).toHaveCount(1) await expect(page.getByText('retry focal')).toHaveCount(1)
await expect(page.getByText('thread root')).toBeVisible() await expect(page.getByText('thread root')).toBeVisible()
await expect(page.getByText('thread page 1')).toBeVisible() await expect(page.getByText('thread page 1')).toBeVisible()
const alert = page await expect(threadPage.continuationErrorLocator).toHaveCount(1)
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
await expect(alert).toHaveCount(1)
await expect(page.getByText('retry focal')).toHaveCount(1) await expect(page.getByText('retry focal')).toHaveCount(1)
await alert.getByRole('button', { name: '再試行' }).click() await threadPage.retryContinuation()
await expect(page.getByText('thread page 2')).toBeVisible() await expect(page.getByText('thread page 2')).toBeVisible()
await expect(alert).toHaveCount(0) await expect(threadPage.continuationErrorLocator).toHaveCount(0)
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(threadPage.endOfFeedLocator).toBeVisible()
await expect( await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
page.getByRole('button', { name: mutationControlNames }),
).toHaveCount(0)
}) })
test('searches Top posts from followed accounts', async ({ page }) => { test('searches Top posts from followed accounts', async ({
await openReader(page, '/search?q=&product=Latest&following=false') page,
await expect(page.locator('article')).toHaveCount(0) searchPage,
await page.getByLabel('検索語').fill('AI lang:ja') }) => {
await page.getByLabel('人気順').check() await searchPage.goTo()
await page.getByLabel('フォロー中のみ').check() await expect(searchPage.articlesLocator).toHaveCount(0)
await expect(page.getByLabel('フォロー中のみ')).toBeChecked() await searchPage.searchTopFromFollowing('AI lang:ja')
await page.getByRole('button', { name: '検索' }).click() await expect(searchPage.followingOnlyLocator).toBeChecked()
await expect(page).toHaveURL(/following=true/) await expect(page).toHaveURL(/following=true/)
await expect(page.getByText('Top · follows page 1')).toBeVisible() await expect(page.getByText('Top · follows page 1')).toBeVisible()
await expect(page.getByText('Top · follows page 2')).toBeVisible() await expect(page.getByText('Top · follows page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(searchPage.endOfFeedLocator).toBeVisible()
}) })
test('retains the first page through a pagination error and retries explicitly', async ({ test('retains the first page through a pagination error and retries explicitly', async ({
page, page,
searchPage,
}, testInfo) => { }, testInfo) => {
await submitSearch(page, `retry-${testInfo.project.name}`) await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`retry-${testInfo.project.name}`)
await expect(page.getByText('Latest · all page 1')).toBeVisible() await expect(page.getByText('Latest · all page 1')).toBeVisible()
const laterPageAlert = page await expect(searchPage.continuationErrorLocator).toHaveCount(1)
.getByRole('alert')
.filter({ hasText: '続きの投稿を取得できませんでした。' })
await expect(laterPageAlert).toHaveCount(1)
await expect(page.getByText('Latest · all page 1')).toBeVisible() await expect(page.getByText('Latest · all page 1')).toBeVisible()
await expect(page.getByText('Latest · all page 2')).toHaveCount(0) await expect(page.getByText('Latest · all page 2')).toHaveCount(0)
await laterPageAlert.getByRole('button', { name: '再試行' }).click() await searchPage.retryContinuation()
await expect(page.getByText('Latest · all page 2')).toBeVisible() await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(laterPageAlert).toHaveCount(0) await expect(searchPage.continuationErrorLocator).toHaveCount(0)
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(searchPage.endOfFeedLocator).toBeVisible()
}) })
test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({ test('keeps keyboard focus intentional and discovery or mutation controls absent', async ({
page, searchPage,
}) => { }) => {
await openReader(page, '/search?q=&product=Latest&following=false') await searchPage.goTo()
await tabTo(page, page.getByRole('link', { name: 'ユーザー' })) await searchPage.tabTo(
await tabTo(page, page.getByRole('link', { name: '検索' })) searchPage.page.getByRole('link', { name: 'ユーザー' }),
await tabTo(page, page.getByRole('link', { name: 'リスト' }))
await tabTo(page, page.getByLabel('検索語'))
const ranking = page.getByLabel('最新順')
await expect(ranking).toBeChecked()
await tabTo(page, ranking)
await tabTo(page, page.getByLabel('フォロー中のみ'))
await tabTo(page, page.getByRole('button', { name: '検索' }))
await expect(page.getByRole('link', { name: ambientLinkNames })).toHaveCount(
0,
) )
await expect( await searchPage.tabTo(searchPage.page.getByRole('link', { name: '検索' }))
page.getByRole('button', { name: mutationControlNames }), await searchPage.tabTo(searchPage.page.getByRole('link', { name: 'リスト' }))
).toHaveCount(0) await searchPage.tabTo(searchPage.queryInputLocator)
await expect( await expect(searchPage.latestRankingLocator).toBeChecked()
page.getByRole('link', { name: mutationControlNames }), await searchPage.tabTo(searchPage.latestRankingLocator)
).toHaveCount(0) await searchPage.tabTo(searchPage.followingOnlyLocator)
await searchPage.tabTo(searchPage.searchButtonLocator)
await expect(searchPage.ambientLinksLocator).toHaveCount(0)
await expect(searchPage.mutationButtonsLocator).toHaveCount(0)
await expect(searchPage.mutationLinksLocator).toHaveCount(0)
}) })
test('removes loading rail animation for reduced motion', async ({ test('removes loading rail animation for reduced motion', async ({
page, page,
searchPage,
}, testInfo) => { }, testInfo) => {
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
await submitSearch(page, `slow-${testInfo.project.name}`) await searchPage.goTo()
await expect(searchPage.articlesLocator).toHaveCount(0)
await searchPage.search(`slow-${testInfo.project.name}`)
const rail = page.locator('.loading-rail') const rail = searchPage.loadingRailLocator
await expect(rail).toBeVisible() await expect(rail).toBeVisible()
await expect await expect
.poll(() => .poll(() =>
@@ -202,5 +172,5 @@ test('removes loading rail animation for reduced motion', async ({
.toBe('none') .toBe('none')
await expect(page.getByText('Latest · all page 2')).toBeVisible() await expect(page.getByText('Latest · all page 2')).toBeVisible()
await expect(page.getByText('これ以上の投稿はありません。')).toBeVisible() await expect(searchPage.endOfFeedLocator).toBeVisible()
}) })