PlaywrightでNext.js+supabaseアプリの会員機能をテストする。(会員ランクに応じた登録数制限テスト)

2022/07/17に公開

Next.js+supabaseをつかって開発した、米国株チャートアプリの会員機能のe2eテストをPlaywrightで実施しました。

テスト環境前提 (GitHubActions,Vercelの設定については別記事に記載)

・開発フレームワーク:Next.js
・データベース:supabaseの開発用DB環境(本番環境とは別)
・ホスティング:Vercel
・VercelのPreviewの環境変数に、supabasesの開発用DBの情報を設定
・GithubActionsと連携して、テスト自動化

テストシナリオ

・会員のログイン
・ブックマーク登録、株式メモ、株式マーカーをそれぞれ登録数上限の9件まで登録。
・登録数が上限に達した場合、画面にワーニングテキストが表示されるので、それを確認。
・ブックマーク登録、株式メモ、株式マーカーをそれぞれ1件ずつ削除。
・登録数上限のワーニングテキストが消えているかを確認。
・データベースクリーンアップの為、残りの8件も削除。
・会員ページですべての登録データが削除されたかを確認。

技術トピックは以下の3つになります。

・環境変数の適用:global-setup
・Loop処理について
・PageObject

1)環境変数の適用:global-setup

テストファイルからは、直接、Next.jsの環境変数 .env.localにアクセスができないようです。調査した結果、ルート直下に、global-setup.js を配置し、以下の記述を行った上で、テストファイルから、環境変数を読み込む流れとなります。

global-setup.js
module.exports = async config => {
    process.env.BASE_URL = 'https://xxxxxx.xxxx' // テスト環境URL
}

テストファイル内で、以下のように環境変数を読み込みます。
const { BASE_URL } = process.env

※今回のコードでは、この設定がなくても対応できましたが、今後の応用の為に設定しました。

2)ループ処理

テストシナリオで、「データを9個入力する」「データを8個削除する」という部分があります。テストコード、以下4つの内、a)とc)がループ処理対応となります。
a) データ9件入力(1会員あたり入力可能上限値)
b) 入力ステイタス確認
 入力不可のステイタス確認->1件削除->入力可ステイタス確認
c) 残り8件のデータ削除
d) 登録データが削除されたか再確認。

テストコード内でのループ処理には、若干癖があって、forEachが使えない(使いにくい)等の問題がありまして。

a) のループ処理では、for of 構文を使いました。

for (const data of stockList) {}

c) のループ処理では、インデックスを使いたいのでbasicなforLoop文を使いました。

for (let i = 0; i < stockList.length; i++) {}

for of 構文でも、インデックスが使えるはずですが、私の環境ではエラーがでたので、一旦使用を見送りました。

テストコード本体

membership.spec.ts

import { test } from '@playwright/test'
import { LoginPage } from './page-objects/LoginPage'
import { MemberPage } from './page-objects/MemberPage'
import { StockIdPage } from './page-objects/StockIdPage'

const stockList = [
  {
    Ticker: 'A',
    Name: 'Agilent Technologies Inc. Common Stock',
  },
 // 省略、同様のデータがあと8件
]

test.describe('Ck Membership', async () => {
  let loginPage: LoginPage
  let memberPage: MemberPage
  let stockIdPage: StockIdPage
  const { BASE_URL } = process.env

  //  データ入力
  test('Input Data', async ({ page }) => {
    loginPage = new LoginPage(page)
    memberPage = new MemberPage(page)
    stockIdPage = new StockIdPage(page)
    await page.goto(`${BASE_URL}`)
    await page.goto('auth/signin')
    await loginPage.login()

    // 会員ページに自動遷移
    await memberPage.assertLoginEmail()

    for (const data of stockList) {
      await page.goto(`/stocks/${data.Ticker}`)
      console.log(data.Ticker)
      await stockIdPage.inputComment()
      await stockIdPage.clickBookMark()
      await stockIdPage.inputMarker()
    }
  })

  // 入力不可ステイタス確認、1個削除後、入力可ステイタス確認
  test('Check 入力不可ステイタス', async ({ page }) => {
    console.log('Check 入力不可ステイタス Start')
    loginPage = new LoginPage(page)
    memberPage = new MemberPage(page)
    stockIdPage = new StockIdPage(page)
    await page.goto(`${BASE_URL}`)
    await page.goto('auth/signin')
    await loginPage.login()

    // 会員ページに自動遷移
    await memberPage.assertLoginEmail()
    await memberPage.assertCanNotAddBookMark()
    // 個別ページで入力不可確認 /stocks/A   stockList 0番目
    await page.goto(`/stocks/${stockList[0].Ticker}`)
    await stockIdPage.checkCanNotInput()
    // 個別ページで 1個データ削除
    await stockIdPage.clickBookMark()
    await stockIdPage.inputMembersOnly.click()
    page.on('dialog', async (dialog) => {
      await dialog.accept()
    })
    await stockIdPage.deleteMarker()
    await stockIdPage.deleteComment()
    await stockIdPage.checkCanInput()
    // 会員ページで 入力可確認 bookmark,marker,comment
    await memberPage.assertCanDataInput()
    console.log('Check 入力不可ステイタス End')
  })

  // 後処理 データ削除 残り8個
  test(`Clean up Data`, async ({ page }) => {
    loginPage = new LoginPage(page)
    memberPage = new MemberPage(page)
    stockIdPage = new StockIdPage(page)
    await page.goto(`${BASE_URL}`)
    await page.goto('auth/signin')
    await loginPage.login()

    // 会員ページに自動遷移
    await memberPage.assertLoginEmail()

    page.on('dialog', async (dialog) => {
      await dialog.accept()
    })
    for (let i = 0; i < stockList.length; i++) {
      if (i == 0) {
        continue
      } else {
        await page.goto(`/stocks/${stockList[i].Ticker}`)
        console.log(stockList[i].Ticker)
        await stockIdPage.checkTickerTitle(stockList[i].Ticker)
        await stockIdPage.inputMembersOnly.click()
        await stockIdPage.deleteMarkerWithConfirm()
        await stockIdPage.clickBookMark()
        await stockIdPage.deleteCommentWithConfirm()
      }
    }
  })

  // 会員ページで、データ登録が0であることを確認する。
  test('Logout', async ({ page }) => {
    loginPage = new LoginPage(page)
    memberPage = new MemberPage(page)
    stockIdPage = new StockIdPage(page)
    await page.goto(`${BASE_URL}`)
    await page.goto('auth/signin')
    await loginPage.login()
    await memberPage.assertLoginEmail()
    await memberPage.assertNoDataRegistered()
  })
})

テストコードの見通しをよくする為にPlaywrightのPageObjectをつかってリファクタリングしました。

PageObjectは全部で3ファイルです。

  • LoginPage.ts
  • Member.ts
  • StockId.ts

全部乗せてしまうと長くなるので、LoginPage.tsとMember.tsの
コードだけ以下に載せています。

LoginPage.ts

import { Locator, Page } from '@playwright/test'

export class LoginPage {
  // Define Selectors
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly submitButton: Locator

  // Init Selectors
  constructor(page: Page) {
    this.page = page
    this.emailInput = page.locator('input[type="email"]')
    this.passwordInput = page.locator('input[type="password"]')
    this.submitButton = page.locator('data-testid=login-submit')
  }

  // Define login page methods
  async login() {
    await this.emailInput.type('exampleEmail@example.com')
    await this.passwordInput.type('examplePassword')
    await this.submitButton.click()
  }
}

Member.ts

import { expect, Locator, Page } from '@playwright/test'

export class MemberPage {
  // Define Selectors
  readonly page: Page
  readonly body: Locator
  readonly canBookMarkInput: Locator
  readonly canMarkerInput: Locator
  readonly canCommentInput: Locator
  readonly tableRowData: Locator

  // Init Selectors
  constructor(page: Page) {
    this.page = page
    this.body = page.locator('body')
    this.canBookMarkInput = page.locator('data-testid=canBookMarkInput')
    this.canMarkerInput = page.locator('data-testid=canMarkerInput')
    this.canCommentInput = page.locator('data-testid=canCommentInput')
    this.tableRowData = page.locator('td')
  }

  // Assert Functions
  async assertCanBookMarkInput() {
    await expect(this.canBookMarkInput).toContainText('Registration is available.')
  }
  async assertCanMarkerInput() {
    await expect(this.canMarkerInput).toContainText('Registration is available.')
  }
  async assertCanCommentInput() {
    await expect(this.canCommentInput).toContainText('Registration is available.')
  }

  async assertCanDataInput() {
    await this.page.goto(`/member`)
    await expect(this.canBookMarkInput).toContainText('Registration is available.')
    await expect(this.canMarkerInput).toContainText('Registration is available.')
    await expect(this.canCommentInput).toContainText('Registration is available.')
  }

  async assertCanNotAddBookMark() {
    await expect(this.canBookMarkInput).toContainText('Registration limit has been reached')
  }

  async assertLoginEmail() {
    await expect(this.page.locator('data-testid=memberEmail')).toContainText('exampleEmail@example.com')
  }

  async assertNoDataRegistered() {
    await expect(this.tableRowData).not.toBeVisible()
  }
}


まだまた、改善の余地はありますが、PageObjectをつかったおかげで、だいぶすっきりしてきました。

Discussion