目次
- E2Eテストの基礎
- 主要なE2Eテストフレームワーク
- E2Eテストの実装
- 各フレームワークの実装例
- E2Eテストのベストプラクティス
- CI/CDパイプラインへの統合
- テストレポートと分析
- よくある問題と解決策
- E2Eテスト導入のロードマップ
- まとめと次のステップ
- 参考資料とリソース
E2Eテストの基礎
E2Eテストとは
エンドツーエンド(E2E)テストは、ソフトウェアテストの一種で、アプリケーションの動作を実際のユーザーの視点から検証するものです。ユーザーが行う操作のフローを模倣し、システム全体が期待通りに機能することを確認します。
E2Eテストでは、以下の特徴があります:
- 実際のユーザー環境に近い状態でテストを行う
- システムのすべてのコンポーネントとの統合を検証する
- ビジネスプロセス全体をカバーする
- 本番環境に近いテスト環境で実行される
E2Eテストの重要性
E2Eテストが重要である理由は以下の通りです:
-
ユーザー体験の品質保証:実際のユーザーが経験する操作フローをテストすることで、ユーザー体験の品質を確保できます。
-
統合問題の早期発見:コンポーネント同士の統合時に生じる問題を開発サイクルの早い段階で発見できます。
-
回帰テストの自動化:新機能の追加やリファクタリング後も、既存機能が正しく動作することを自動的に確認できます。
-
ビジネスプロセスの検証:技術的な検証だけでなく、ビジネス要件に対するシステムの適合性も検証できます。
-
ドキュメントとしての役割:適切に設計されたE2Eテストは、システムの期待される動作の具体例として機能し、ドキュメントの役割も果たします。
テストピラミッドにおけるE2Eテストの位置づけ
マイク・コーンが提唱した「テストピラミッド」では、E2Eテストはピラミッドの最上部に位置します:
▲
/E\ ← E2Eテスト(少数の重要なシナリオ)
/___\
/ \ ← 統合テスト
/_______\
/ \ ← ユニットテスト(多数の小さなテスト)
テストピラミッドの各層の特徴:
テストの種類 | 実行速度 | コスト | 保守性 | カバレッジ目標 |
---|---|---|---|---|
ユニットテスト | 高速 | 低 | 高 | 70-80% |
統合テスト | 中程度 | 中程度 | 中程度 | 20-30% |
E2Eテスト | 低速 | 高 | 低 | 5-10% |
E2Eテストは実行に時間がかかり、メンテナンスコストも高いため、すべての機能を網羅するのではなく、重要なユーザーフローやビジネスクリティカルな機能に焦点を当てることが重要です。
主要なE2Eテストフレームワーク
Selenium
Seleniumは最も歴史が長く広く使われているE2Eテストツールです。
特徴:
- 多言語サポート(Java, Python, C#, JavaScript, Ruby など)
- 幅広いブラウザサポート
- 豊富なコミュニティとリソース
- WebDriverを通じてブラウザを直接制御
主な構成要素:
- Selenium WebDriver: ブラウザ操作のためのAPI
- Selenium Grid: 分散テスト実行環境
- Selenium IDE: テスト記録・再生ツール(初心者向け)
サンプルコード(Java):
Seleniumを使用したログインテストの例:
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class LoginTest {
private WebDriver driver;
@Before
public void setUp() {
System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
driver = new ChromeDriver();
driver.manage().window().maximize();
}
@Test
public void testLogin() {
// サイトにアクセス
driver.get("https://example.com/login");
// ログイン情報入力
WebElement username = driver.findElement(By.id("username"));
username.sendKeys("testuser");
WebElement password = driver.findElement(By.id("password"));
password.sendKeys("password123");
// ログインボタンクリック
driver.findElement(By.id("login-button")).click();
// ログイン成功の検証
WebElement welcomeMessage = driver.findElement(By.className("welcome-message"));
assertEquals("Welcome, testuser!", welcomeMessage.getText());
}
@After
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
}
公式サイト: https://www.selenium.dev/
Cypress
Cypressは近年人気を集めている、モダンなJavaScript向けE2Eテストフレームワークです。
特徴:
- JavaScriptのみで記述(主にNode.js環境)
- ブラウザ内でテストを実行(Seleniumとは異なるアプローチ)
- 自動待機と再試行メカニズム
- リアルタイムリロードとデバッグのしやすさ
- スクリーンショットとビデオ録画の組み込み機能
サンプルコード:
Cypressを使用したログインテストの例:
describe('ログインテスト', () => {
it('有効な認証情報でログインできる', () => {
// サイトにアクセス
cy.visit('https://example.com/login')
// ログイン情報入力
cy.get('#username').type('testuser')
cy.get('#password').type('password123')
// ログインボタンクリック
cy.get('#login-button').click()
// ログイン成功の検証
cy.get('.welcome-message').should('contain', 'Welcome, testuser!')
// ダッシュボードにリダイレクトされたことを確認
cy.url().should('include', '/dashboard')
})
it('無効な認証情報でエラーを表示する', () => {
cy.visit('https://example.com/login')
cy.get('#username').type('testuser')
cy.get('#password').type('wrongpassword')
cy.get('#login-button').click()
// エラーメッセージの検証
cy.get('.error-message').should('be.visible')
cy.get('.error-message').should('contain', 'Invalid credentials')
})
})
公式サイト: https://www.cypress.io/
Playwright
Microsoft社が開発した比較的新しいツールで、複数のブラウザエンジンをサポートしている点が特徴です。
特徴:
- 複数のブラウザエンジンサポート(Chromium, Firefox, WebKit)
- 自動待機機能
- ヘッドレスモードとビジュアルモードの両方をサポート
- 複数のプログラミング言語(JavaScript/TypeScript, Python, .NET, Java)
- モバイルエミュレーション
- ネットワークインターセプトとモッキング機能
サンプルコード(TypeScript):
Playwrightを使用したログインテストの例:
import { test, expect } from '@playwright/test';
test.describe('認証テスト', () => {
test('有効なユーザーでログインできる', async ({ page }) => {
// サイトにアクセス
await page.goto('https://example.com/login');
// ログイン情報入力
await page.fill('#username', 'testuser');
await page.fill('#password', 'password123');
// ログインボタンクリック
await page.click('#login-button');
// ログイン成功の検証
await expect(page.locator('.welcome-message')).toContainText('Welcome, testuser!');
// URLの検証
await expect(page).toHaveURL(/.*dashboard/);
});
test('無効なパスワードでエラーを表示する', async ({ page }) => {
await page.goto('https://example.com/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'wrongpassword');
await page.click('#login-button');
// エラーメッセージの検証
await expect(page.locator('.error-message')).toBeVisible();
await expect(page.locator('.error-message')).toContainText('Invalid credentials');
});
});
公式サイト: https://playwright.dev/
TestCafe
DevExpressが開発したE2Eテストフレームワークで、セットアップが非常に簡単なことが特徴です。
特徴:
- プラグインレスアーキテクチャ(WebDriverやその他のブラウザプラグインが不要)
- 同時並行テスト実行の組み込みサポート
- 自動待機メカニズム
- スクリーンショットとビデオのサポート
- フリーで商用利用可能
サンプルコード:
TestCafeを使用したログインテストの例:
import { Selector } from 'testcafe';
fixture `ログインプロセス`
.page `https://example.com/login`;
test('有効な認証情報でログインできる', async t => {
await t
.typeText('#username', 'testuser')
.typeText('#password', 'password123')
.click('#login-button')
// ログイン成功の検証
.expect(Selector('.welcome-message').innerText).contains('Welcome, testuser!')
.expect(Selector('.dashboard-header').exists).ok()
.expect(t.eval(() => window.location.href)).contains('/dashboard');
});
test('無効な認証情報でエラーを表示する', async t => {
await t
.typeText('#username', 'testuser')
.typeText('#password', 'wrongpassword')
.click('#login-button')
// エラーメッセージの検証
.expect(Selector('.error-message').visible).ok()
.expect(Selector('.error-message').innerText).contains('Invalid credentials');
});
公式サイト: https://testcafe.io/
Puppeteer
Googleが開発したNode.jsライブラリで、ChromiumまたはChromeの制御に特化しています。
特徴:
- ChromiumおよびChromeに最適化
- ヘッドレスモードのサポート
- ネットワークトラフィックの制御
- PDFの生成
- DevToolsプロトコルへの直接アクセス
サンプルコード:
Puppeteerを使用したログインテストの例:
const puppeteer = require('puppeteer');
const assert = require('assert');
(async () => {
const browser = await puppeteer.launch({
headless: false // プロセスを視覚的に確認するためにheadlessモードをオフに
});
const page = await browser.newPage();
try {
// サイトにアクセス
await page.goto('https://example.com/login');
// ログイン情報入力
await page.type('#username', 'testuser');
await page.type('#password', 'password123');
// ログインボタンクリックとナビゲーション完了を待つ
await Promise.all([
page.click('#login-button'),
page.waitForNavigation()
]);
// ログイン成功の検証
const welcomeText = await page.$eval('.welcome-message', el => el.textContent);
assert(welcomeText.includes('Welcome, testuser!'));
// URLの検証
const url = page.url();
assert(url.includes('/dashboard'));
console.log('Login test passed successfully!');
} catch (error) {
console.error('Test failed:', error);
} finally {
await browser.close();
}
})();
公式サイト: https://pptr.dev/
Appium (モバイル)
モバイルアプリケーション(iOSとAndroid)のテスト自動化に特化したフレームワークです。
特徴:
- iOSとAndroidの両プラットフォームをサポート
- WebDriverプロトコルの拡張
- ネイティブアプリ、ハイブリッドアプリ、モバイルWebのテストが可能
- 複数のプログラミング言語をサポート
サンプルコード(Java):
Appiumを使用したモバイルアプリの簡単なログインテストの例:
import io.appium.java_client.AppiumDriver;
import io.appium.java_client.MobileElement;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.testng.Assert;
import org.testng.annotations.AfterTest;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.Test;
import java.net.URL;
public class AppiumLoginTest {
private AppiumDriver<MobileElement> driver;
@BeforeTest
public void setUp() throws Exception {
DesiredCapabilities caps = new DesiredCapabilities();
caps.setCapability("platformName", "Android");
caps.setCapability("deviceName", "Android Emulator");
caps.setCapability("app", "/path/to/your/app.apk");
caps.setCapability("automationName", "UiAutomator2");
driver = new AndroidDriver<>(new URL("http://localhost:4723/wd/hub"), caps);
}
@Test
public void testLogin() {
// ユーザー名入力
MobileElement usernameField = driver.findElementById("com.example.app:id/username");
usernameField.sendKeys("testuser");
// パスワード入力
MobileElement passwordField = driver.findElementById("com.example.app:id/password");
passwordField.sendKeys("password123");
// ログインボタンタップ
MobileElement loginButton = driver.findElementById("com.example.app:id/login_button");
loginButton.click();
// 成功メッセージの検証
MobileElement welcomeMessage = driver.findElementById("com.example.app:id/welcome_message");
Assert.assertTrue(welcomeMessage.isDisplayed());
Assert.assertEquals(welcomeMessage.getText(), "Welcome, testuser!");
}
@AfterTest
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
}
公式サイト: https://appium.io/
フレームワーク比較表
以下の表は、主要なE2Eテストフレームワークの比較です:
フレームワーク | 言語サポート | ブラウザサポート | 学習曲線 | 実行速度 | セットアップの容易さ | コミュニティサポート | モバイルサポート |
---|---|---|---|---|---|---|---|
Selenium | 多言語 | すべての主要ブラウザ | 中~高 | 中程度 | やや複雑 | 非常に強い | 可能(Appiumと連携) |
Cypress | JavaScript | Chromium系、Firefox | 低~中 | 高速 | 簡単 | 強い | 限定的 |
Playwright | JavaScript, TypeScript, Python, Java, .NET | Chromium, Firefox, WebKit | 中程度 | 高速 | 比較的簡単 | 成長中 | エミュレーションのみ |
TestCafe | JavaScript | すべての主要ブラウザ | 低 | 中~高 | 非常に簡単 | 中程度 | 限定的 |
Puppeteer | JavaScript | Chromium, Chrome | 中程度 | 高速 | 簡単 | 強い | エミュレーションのみ |
Appium | 多言語 | N/A | 高 | 中程度 | 複雑 | 強い | ネイティブサポート |
フレームワーク選定の参考ポイント:
- プロジェクトの技術スタック: 開発言語に合わせたフレームワークを選ぶと導入が容易
- テスト要件: ブラウザ範囲、モバイル対応の必要性、特殊機能(PWA、SPAなど)
- チームのスキルセット: 学習コストを考慮
- テスト実行環境: CI/CD環境との統合性
- メンテナンス性: 長期的な保守のしやすさ
E2Eテストの実装
テスト環境のセットアップ
効果的なE2Eテスト環境をセットアップするためのステップ:
-
テスト要件の明確化
- テスト対象のブラウザとバージョン
- デバイスとプラットフォーム(デスクトップ、モバイル)
- テスト実行環境(ローカル、CI/CD、クラウド)
-
テスト用データベースの準備
- 独立したテスト用DBの構築
- テストデータの初期化と復元メカニズム
- データのシーディング戦略
-
テスト用環境変数の管理
- 環境ごとの設定(開発、テスト、ステージング)
- シークレット情報の安全な管理
- 設定ファイルの分離
-
開発環境でのセットアップ例(Playwright + TypeScript)
// playwright.config.ts
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: './tests',
timeout: 30000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit',
use: { browserName: 'webkit' },
},
{
name: 'mobile-chrome',
use: {
browserName: 'chromium',
...devices['Pixel 5'],
},
},
{
name: 'mobile-safari',
use: {
browserName: 'webkit',
...devices['iPhone 12'],
},
},
],
};
export default config;
- CI/CD環境でのセットアップ例(GitHub Actions)
# .github/workflows/e2e-tests.yml
name: E2E Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Setup test database
run: npm run db:setup:test
- name: Start application
run: npm run start:test &
- name: Run E2E tests
env:
BASE_URL: http://localhost:3000
NODE_ENV: test
run: npm run test:e2e
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results/
テストケースの設計
効果的なE2Eテストケースを設計するためのアプローチ:
-
ユーザーストーリーに基づく設計
- ユーザーの視点からシナリオを考える
- 「ユーザーとして、〜ができる」の形式
-
クリティカルパスの特定
- ビジネス上重要なフロー
- 高頻度で使用される機能
- 障害が発生すると影響が大きい機能
-
テストケース設計の原則
- 独立性:各テストは他のテストに依存しない
- 繰り返し実行可能:何度実行しても同じ結果
- 自己完結性:テスト自身でデータを準備・クリーンアップ
-
テストケース記述の例(Gherkinフォーマット)
Feature: ユーザー認証
Scenario: 有効な認証情報でログインする
Given ユーザーがログインページにアクセスする
When ユーザーが有効なユーザー名"testuser"とパスワード"password123"を入力する
And ログインボタンをクリックする
Then ダッシュボードページが表示される
And ウェルカムメッセージ"Welcome, testuser!"が表示される
Scenario: 無効なパスワードでログインに失敗する
Given ユーザーがログインページにアクセスする
When ユーザーが有効なユーザー名"testuser"と無効なパスワード"wrongpassword"を入力する
And ログインボタンをクリックする
Then エラーメッセージ"Invalid credentials"が表示される
And ユーザーはログインページに留まる
-
テストケース優先順位付け
- P0: クリティカルパス(最優先)
- P1: 主要機能
- P2: 副次的機能
- P3: エッジケース
セレクタの選定戦略
テスト対象の要素を特定するためのセレクタ選定戦略:
-
良いセレクタの特徴
- 一意的(ページ内で唯一の要素を指定)
- 安定性(UIの変更に強い)
- 意図が明確(何を選択しているかわかりやすい)
-
セレクタの種類と優先順位
セレクタタイプ | 推奨度 | 安定性 | 例 |
---|---|---|---|
データテスト属性 | ★★★★★ | 高 | [data-testid="login-button"] |
ID | ★★★★☆ | 中~高 | #login-button |
ラベル/テキスト | ★★★☆☆ | 中 | :text("ログイン") |
クラス名 | ★★☆☆☆ | 低~中 | .btn-primary |
タグ+属性組み合わせ | ★★☆☆☆ | 中 | button[type="submit"] |
XPath | ★☆☆☆☆ | 低 | //div[@class="form"]/button |
CSS位置セレクタ | ☆☆☆☆☆ | 非常に低 | .form > div:nth-child(2) |
- データテスト属性の使用例
HTMLに専用の属性を追加:
<button data-testid="login-button" class="btn btn-primary">ログイン</button>
テストコードでの利用(Playwright):
await page.click('[data-testid="login-button"]');
- 安定したセレクタの実装例(Cypress)
// 悪い例:不安定なセレクタ
cy.get('.form > div:nth-child(2) > input').type('testuser');
cy.get('.btn-primary').click();
// 良い例:安定したセレクタ
cy.get('[data-testid="username-input"]').type('testuser');
cy.get('[data-testid="login-button"]').click();
-
セレクタ戦略のベストプラクティス
- アプリケーションコードにテスト用の属性を追加(data-testid など)
- 共通のセレクタをページオブジェクトやユーティリティに抽出
- CSSセレクタよりもテスト専用の属性を優先
- ローカライズされたテキストに依存しない(多言語対応の場合)
テストデータの管理
効果的なテストデータ管理戦略:
-
テストデータの種類
- 静的データ(固定値、定数)
- 動的データ(生成値、ランダムデータ)
- フィクスチャ(事前定義されたデータセット)
-
テストデータ管理の原則
- 分離:テスト間でデータを分離
- 独立性:テストはデータを自己完結的に管理
- 可読性:データの意図が明確
- 再現性:テスト実行ごとに一貫した結果
-
テストデータ管理のアプローチ
フィクスチャファイル(Cypressの例):
// cypress/fixtures/users.json
{
"validUser": {
"username": "testuser",
"password": "password123",
"email": "test@example.com"
},
"invalidUser": {
"username": "nonexistent",
"password": "wrongpassword",
"email": "invalid@example.com"
}
}
// cypress/e2e/login.cy.js
describe('ログイン機能', () => {
beforeEach(() => {
cy.fixture('users').as('userData');
});
it('有効なユーザーでログインできる', function() {
const { username, password } = this.userData.validUser;
cy.visit('/login');
cy.get('[data-testid="username-input"]').type(username);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
cy.url().should('include', '/dashboard');
});
});
ファクトリーパターン(Playwrightの例):
// tests/factories/user.ts
import { faker } from '@faker-js/faker';
export interface User {
username: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}
export function createUser(overrides?: Partial<User>): User {
return {
username: faker.internet.userName(),
email: faker.internet.email(),
password: faker.internet.password(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
...overrides
};
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { createUser } from './factories/user';
test('ユーザー登録とログイン', async ({ page, request }) => {
// テストデータ生成
const user = createUser();
// APIを使用してユーザーを作成(テスト準備)
const response = await request.post('/api/users', { data: user });
expect(response.ok()).toBeTruthy();
// UIでログインテスト
await page.goto('/login');
await page.fill('[data-testid="username-input"]', user.username);
await page.fill('[data-testid="password-input"]', user.password);
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL(/.*dashboard/);
});
- データベースシーディング(TestCafeの例)
// db/seed.js
const { Pool } = require('pg');
const bcrypt = require('bcrypt');
async function seedTestDatabase() {
const pool = new Pool({
host: process.env.DB_HOST,
database: process.env.TEST_DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
});
try {
// テストDBをクリア
await pool.query('TRUNCATE users CASCADE');
// テストユーザーを作成
const hashedPassword = await bcrypt.hash('password123', 10);
await pool.query(
`INSERT INTO users (username, email, password)
VALUES ($1, $2, $3) RETURNING id`,
['testuser', 'test@example.com', hashedPassword]
);
console.log('Test database seeded successfully');
} catch (error) {
console.error('Error seeding test database:', error);
throw error;
} finally {
await pool.end();
}
}
// テスト実行前にシード処理を実行
if (require.main === module) {
seedTestDatabase();
}
module.exports = { seedTestDatabase };
// testcafe setup
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
fixture `ログインテスト`
.page `http://localhost:3000/login`
.beforeEach(async t => {
// テスト実行前にデータベースを初期化
await execAsync('node db/seed.js');
});
- APIを活用したテストデータセットアップ
// tests/setup/api-helpers.js
async function createTestUser(request, userData) {
const response = await request.post('/api/users', {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.API_TOKEN}`
},
data: userData
});
return response.json();
}
async function cleanupTestUser(request, userId) {
await request.delete(`/api/users/${userId}`, {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
});
}
// テストファイルでの使用例
test.describe('ユーザー機能テスト', () => {
let testUser;
test.beforeAll(async ({ request }) => {
testUser = await createTestUser(request, {
username: 'temporaryuser',
email: 'temp@example.com',
password: 'temppass123'
});
});
test.afterAll(async ({ request }) => {
await cleanupTestUser(request, testUser.id);
});
test('ユーザープロフィールを表示できる', async ({ page }) => {
await page.goto(`/profile/${testUser.username}`);
await expect(page.locator('h1')).toContainText(testUser.username);
});
});
アサーションの書き方
効果的なアサーション(検証)の作成方法:
-
良いアサーションの特徴
- 明確:何をテストしているかが明確
- 特定的:具体的な期待値と結果を検証
- 有意義:ビジネス要件に関連した検証
-
アサーションの種類
- 要素の存在確認
- テキスト内容の検証
- 表示/非表示の状態確認
- URL/ページ遷移の確認
- DOM要素の属性確認
- 状態の変化の確認
-
各フレームワークでのアサーション例
Cypress:
// 要素の存在と内容
cy.get('.welcome-message').should('exist');
cy.get('.welcome-message').should('contain', 'Welcome, testuser!');
// 複数の検証を連鎖
cy.get('.dashboard')
.should('be.visible')
.and('contain', 'Dashboard')
.and('have.class', 'active');
// 否定的なアサーション
cy.get('.error-message').should('not.exist');
// URL検証
cy.url().should('include', '/dashboard');
// DOM属性の検証
cy.get('button').should('have.attr', 'disabled');
// 状態変化の検証
cy.get('.loading').should('exist');
cy.get('.loading').should('not.exist');
Playwright:
// 要素の存在と内容
await expect(page.locator('.welcome-message')).toBeVisible();
await expect(page.locator('.welcome-message')).toContainText('Welcome, testuser!');
// 否定的なアサーション
await expect(page.locator('.error-message')).toBeHidden();
// URL検証
await expect(page).toHaveURL(/.*dashboard/);
// DOM属性の検証
await expect(page.locator('button')).toHaveAttribute('disabled', '');
// 状態の検証
const count = await page.locator('.item').count();
expect(count).toBe(5);
TestCafe:
// 要素の存在と内容
await t.expect(Selector('.welcome-message').exists).ok();
await t.expect(Selector('.welcome-message').innerText).contains('Welcome, testuser!');
// 否定的なアサーション
await t.expect(Selector('.error-message').exists).notOk();
// URL検証
await t.expect(getLocation()).contains('/dashboard');
// DOM属性の検証
await t.expect(Selector('button').hasAttribute('disabled')).ok();
// 状態の検証
const itemCount = await Selector('.item').count;
await t.expect(itemCount).eql(5);
-
アサーションのベストプラクティス
- 一つのテストには関連するアサーションのみを含める
- ビジュアルの詳細(色や正確な位置など)ではなく機能的な動作を検証
- 明示的な待機を使用して非同期操作を安定させる
- 失敗した場合に意味のあるエラーメッセージが出るようにする
-
カスタムアサーションの作成(Cypressの例)
// cypress/support/commands.js
Cypress.Commands.add('shouldHaveValidationError', (selector, message) => {
cy.get(selector)
.parent()
.find('.validation-error')
.should('be.visible')
.and('contain', message);
});
// 使用例
cy.get('#password')
.type('123')
.blur();
cy.shouldHaveValidationError('#password', 'Password must be at least 8 characters');
各フレームワークの実装例
Seleniumによる実装
Seleniumを使用したJavaでのE2Eテスト実装例:
- プロジェクト構成
src/
├── test/
│ ├── java/
│ │ └── com/example/tests/
│ │ ├── BaseTest.java
│ │ ├── LoginTest.java
│ │ └── pages/
│ │ ├── BasePage.java
│ │ ├── LoginPage.java
│ │ └── DashboardPage.java
│ └── resources/
│ ├── test.properties
│ └── testng.xml
pom.xml
- pom.xml(依存関係)
<dependencies>
<!-- Selenium WebDriver -->
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.8.0</version>
</dependency>
<!-- TestNG -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.7.0</version>
<scope>test</scope>
</dependency>
<!-- WebDriverManager -->
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.3.2</version>
</dependency>
</dependencies>
- ベーステストクラス
package com.example.tests;
import io.github.bonigarcia.wdm.WebDriverManager;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Parameters;
import java.io.FileInputStream;
import java.time.Duration;
import java.util.Properties;
public class BaseTest {
protected WebDriver driver;
protected Properties prop;
@BeforeMethod
@Parameters({"browser"})
public void setUp(String browser) {
try {
prop = new Properties();
FileInputStream ip = new FileInputStream("src/test/resources/test.properties");
prop.load(ip);
if (browser.equalsIgnoreCase("chrome")) {
WebDriverManager.chromedriver().setup();
ChromeOptions options = new ChromeOptions();
// CI環境の場合はヘッドレスモードで実行
if (System.getenv("CI") != null) {
options.addArguments("--headless");
}
driver = new ChromeDriver(options);
} else if (browser.equalsIgnoreCase("firefox")) {
WebDriverManager.firefoxdriver().setup();
driver = new FirefoxDriver();
}
driver.manage().window().maximize();
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(20));
} catch (Exception e) {
e.printStackTrace();
}
}
@AfterMethod
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
// 共通ユーティリティメソッド
public String getBaseUrl() {
return prop.getProperty("base.url");
}
}
- ページオブジェクトモデル
package com.example.tests.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
public class BasePage {
protected WebDriver driver;
protected WebDriverWait wait;
public BasePage(WebDriver driver) {
this.driver = driver;
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
PageFactory.initElements(driver, this);
}
protected void waitForElementVisible(WebElement element) {
wait.until(ExpectedConditions.visibilityOf(element));
}
protected void waitForElementClickable(WebElement element) {
wait.until(ExpectedConditions.elementToBeClickable(element));
}
}
package com.example.tests.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
public class LoginPage extends BasePage {
@FindBy(id = "username")
private WebElement usernameInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(id = "login-button")
private WebElement loginButton;
@FindBy(className = "error-message")
private WebElement errorMessage;
public LoginPage(WebDriver driver) {
super(driver);
}
public LoginPage navigateTo(String baseUrl) {
driver.get(baseUrl + "/login");
return this;
}
public LoginPage enterUsername(String username) {
waitForElementVisible(usernameInput);
usernameInput.clear();
usernameInput.sendKeys(username);
return this;
}
public LoginPage enterPassword(String password) {
passwordInput.clear();
passwordInput.sendKeys(password);
return this;
}
public DashboardPage clickLoginExpectingSuccess() {
waitForElementClickable(loginButton);
loginButton.click();
return new DashboardPage(driver);
}
public LoginPage clickLoginExpectingFailure() {
waitForElementClickable(loginButton);
loginButton.click();
return this;
}
public boolean isErrorMessageDisplayed() {
waitForElementVisible(errorMessage);
return errorMessage.isDisplayed();
}
public String getErrorMessage() {
waitForElementVisible(errorMessage);
return errorMessage.getText();
}
}
package com.example.tests.pages;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
public class DashboardPage extends BasePage {
@FindBy(className = "welcome-message")
private WebElement welcomeMessage;
@FindBy(className = "dashboard-header")
private WebElement dashboardHeader;
public DashboardPage(WebDriver driver) {
super(driver);
}
public boolean isDashboardLoaded() {
waitForElementVisible(dashboardHeader);
return dashboardHeader.isDisplayed();
}
public String getWelcomeMessage() {
waitForElementVisible(welcomeMessage);
return welcomeMessage.getText();
}
}
- テストケース
package com.example.tests;
import com.example.tests.pages.LoginPage;
import org.testng.Assert;
import org.testng.annotations.Test;
public class LoginTest extends BaseTest {
@Test
public void testSuccessfulLogin() {
LoginPage loginPage = new LoginPage(driver);
loginPage.navigateTo(getBaseUrl())
.enterUsername("testuser")
.enterPassword("password123");
var dashboardPage = loginPage.clickLoginExpectingSuccess();
Assert.assertTrue(dashboardPage.isDashboardLoaded(), "ダッシュボードが正しく表示されていません");
Assert.assertEquals(dashboardPage.getWelcomeMessage(), "Welcome, testuser!", "ウェルカムメッセージが正しくありません");
}
@Test
public void testFailedLogin() {
LoginPage loginPage = new LoginPage(driver);
loginPage.navigateTo(getBaseUrl())
.enterUsername("testuser")
.enterPassword("wrongpassword")
.clickLoginExpectingFailure();
Assert.assertTrue(loginPage.isErrorMessageDisplayed(), "エラーメッセージが表示されていません");
Assert.assertEquals(loginPage.getErrorMessage(), "Invalid credentials", "エラーメッセージが正しくありません");
}
}
- TestNG設定ファイル
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="TestSuite" parallel="tests" thread-count="2">
<test name="ChromeTests">
<parameter name="browser" value="chrome"/>
<classes>
<class name="com.example.tests.LoginTest"/>
<!-- その他のテストクラス -->
</classes>
</test>
<test name="FirefoxTests">
<parameter name="browser" value="firefox"/>
<classes>
<class name="com.example.tests.LoginTest"/>
<!-- その他のテストクラス -->
</classes>
</test>
</suite>
Cypressによる実装
Cypressを使用したE2Eテスト実装例:
- プロジェクト構成
cypress/
├── e2e/
│ ├── login.cy.js
│ └── dashboard.cy.js
├── fixtures/
│ ├── users.json
│ └── products.json
├── support/
│ ├── commands.js
│ └── e2e.js
├── pages/
│ ├── LoginPage.js
│ └── DashboardPage.js
└── downloads/
cypress.config.js
package.json
- 依存関係(package.json)
{
"scripts": {
"cypress:open": "cypress open",
"cypress:run": "cypress run",
"test:e2e": "cypress run"
},
"devDependencies": {
"cypress": "^12.5.0",
"cypress-xpath": "^2.0.1"
}
}
- Cypress設定ファイル
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// イベントリスナーの設定
},
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
defaultCommandTimeout: 5000
},
});
- 共通コマンドの拡張
// cypress/support/commands.js
Cypress.Commands.add('login', (username, password) => {
cy.visit('/login');
cy.get('[data-testid="username-input"]').type(username);
cy.get('[data-testid="password-input"]').type(password);
cy.get('[data-testid="login-button"]').click();
});
Cypress.Commands.add('logout', () => {
cy.get('[data-testid="user-menu"]').click();
cy.get('[data-testid="logout-button"]').click();
cy.url().should('include', '/login');
});
// フォームフィールドの検証エラーチェック
Cypress.Commands.add('shouldHaveFieldError', (fieldId, errorMessage) => {
cy.get(`[data-testid="${fieldId}-error"]`)
.should('be.visible')
.and('contain', errorMessage);
});
- ページオブジェクトモデル
// cypress/pages/LoginPage.js
class LoginPage {
visit() {
cy.visit('/login');
return this;
}
fillUsername(username) {
cy.get('[data-testid="username-input"]').clear().type(username);
return this;
}
fillPassword(password) {
cy.get('[data-testid="password-input"]').clear().type(password);
return this;
}
submit() {
cy.get('[data-testid="login-button"]').click();
return this;
}
getErrorMessage() {
return cy.get('[data-testid="login-error"]');
}
}
export default new LoginPage();
// cypress/pages/DashboardPage.js
class DashboardPage {
getWelcomeMessage() {
return cy.get('[data-testid="welcome-message"]');
}
getDataTable() {
return cy.get('[data-testid="data-table"]');
}
getTableRows() {
return this.getDataTable().find('tbody tr');
}
navigateToUserProfile() {
cy.get('[data-testid="user-menu"]').click();
cy.get('[data-testid="profile-link"]').click();
return this;
}
}
export default new DashboardPage();
- テストケース
// cypress/e2e/login.cy.js
import LoginPage from '../pages/LoginPage';
import DashboardPage from '../pages/DashboardPage';
describe('ログイン機能', () => {
beforeEach(() => {
cy.fixture('users').as('userData');
});
it('有効な認証情報でログインできる', function() {
const { username, password } = this.userData.validUser;
LoginPage
.visit()
.fillUsername(username)
.fillPassword(password)
.submit();
// 成功の検証
cy.url().should('include', '/dashboard');
DashboardPage.getWelcomeMessage().should('contain', `Welcome, ${username}`);
});
it('無効なパスワードでログインに失敗する', function() {
const { username } = this.userData.validUser;
LoginPage
.visit()
.fillUsername(username)
.fillPassword('wrongpassword')
.submit();
// 失敗の検証
cy.url().should('include', '/login');
LoginPage.getErrorMessage()
.should('be.visible')
.and('contain', 'Invalid credentials');
});
it('入力検証が機能する', function() {
LoginPage.visit();
// 空のユーザー名でサブミット
LoginPage
.fillPassword('anypassword')
.submit();
cy.shouldHaveFieldError('username', 'Username is required');
// 短すぎるパスワードでサブミット
LoginPage
.fillUsername('anyuser')
.fillPassword('123')
.submit();
cy.shouldHaveFieldError('password', 'Password must be at least 8 characters');
});
});
// cypress/e2e/dashboard.cy.js
import DashboardPage from '../pages/DashboardPage';
describe('ダッシュボード機能', () => {
beforeEach(() => {
cy.fixture('users').as('userData');
// 各テスト前にログイン
cy.get('@userData').then(userData => {
const { username, password } = userData.validUser;
cy.login(username, password);
});
// ダッシュボードページにいることを確認
cy.url().should('include', '/dashboard');
});
it('データテーブルが正しく表示される', () => {
DashboardPage.getDataTable().should('be.visible');
DashboardPage.getTableRows().should('have.length.at.least', 1);
});
it('ユーザープロフィールに移動できる', () => {
DashboardPage.navigateToUserProfile();
cy.url().should('include', '/profile');
});
it('ログアウトできる', () => {
cy.logout();
cy.url().should('include', '/login');
});
});
- APIを活用したテスト設定
// cypress/e2e/tasks.cy.js
describe('タスク管理機能', () => {
beforeEach(() => {
// APIを使用してテストデータをセットアップ
cy.request({
method: 'POST',
url: '/api/test/seed-tasks',
body: {
count: 5,
userId: 'test-user-id'
},
}).then(response => {
expect(response.status).to.eq(200);
cy.wrap(response.body.taskIds).as('taskIds');
});
// ログイン
cy.login('testuser', 'password123');
// タスクページに移動
cy.visit('/tasks');
});
afterEach(() => {
// テストデータをクリーンアップ
cy.get('@taskIds').then(taskIds => {
cy.request({
method: 'DELETE',
url: '/api/test/cleanup-tasks',
body: { taskIds }
});
});
});
it('タスク一覧が表示される', () => {
cy.get('[data-testid="task-list"]').should('be.visible');
cy.get('[data-testid="task-item"]').should('have.length', 5);
});
it('タスクを追加できる', () => {
const newTask = 'テスト用新規タスク';
cy.get('[data-testid="new-task-input"]').type(newTask);
cy.get('[data-testid="add-task-button"]').click();
cy.get('[data-testid="task-item"]').should('have.length', 6);
cy.get('[data-testid="task-item"]').first().should('contain', newTask);
});
});
Playwrightによる実装
Playwrightを使用したTypeScriptでのE2Eテスト実装例:
- プロジェクト構成
tests/
├── e2e/
│ ├── auth.spec.ts
│ └── dashboard.spec.ts
├── fixtures/
│ ├── users.json
│ └── products.json
├── pages/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── utils/
│ ├── test-helpers.ts
│ └── api-helpers.ts
└── setup/
└── global-setup.ts
package.json
playwright.config.ts
tsconfig.json
- 依存関係(package.json)
{
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.30.0",
"@types/node": "^18.11.18",
"typescript": "^4.9.5"
}
}
- Playwright設定ファイル
// playwright.config.ts
import { PlaywrightTestConfig, devices } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: './tests/e2e',
timeout: 30000,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results.json' }]
],
globalSetup: './tests/setup/global-setup.ts',
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
},
],
};
export default config;
- グローバルセットアップ
// tests/setup/global-setup.ts
import { FullConfig } from '@playwright/test';
import { createTestUser, cleanupAllTestUsers } from '../utils/api-helpers';
async function globalSetup(config: FullConfig) {
// テスト環境の初期化
if (process.env.CI) {
console.log('Running in CI environment, using mocked APIs');
}
// テスト環境で共通して使用するテストユーザーの作成
const defaultUser = {
username: 'global-test-user',
email: 'global-test@example.com',
password: 'Password123'
};
try {
// APIを使用してテストユーザーを作成し、グローバル状態に保存
await createTestUser(defaultUser);
process.env.TEST_USER = JSON.stringify(defaultUser);
} catch (error) {
console.error('Failed to create test user:', error);
}
}
export default globalSetup;
- ページオブジェクトモデル
// tests/pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.usernameInput = page.locator('[data-testid="username-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.loginButton = page.locator('[data-testid="login-button"]');
this.errorMessage = page.locator('[data-testid="login-error"]');
}
async goto() {
await this.page.goto('/login');
}
async fillUsername(username: string) {
await this.usernameInput.fill(username);
}
async fillPassword(password: string) {
await this.passwordInput.fill(password);
}
async clickLogin() {
await this.loginButton.click();
}
async login(username: string, password: string) {
await this.fillUsername(username);
await this.fillPassword(password);
await this.clickLogin();
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toBeVisible();
await expect(this.errorMessage).toContainText(message);
}
}
// tests/pages/DashboardPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly page: Page;
readonly welcomeMessage: Locator;
readonly dataTable: Locator;
readonly userMenu: Locator;
readonly logoutButton: Locator;
constructor(page: Page) {
this.page = page;
this.welcomeMessage = page.locator('[data-testid="welcome-message"]');
this.dataTable = page.locator('[data-testid="data-table"]');
this.userMenu = page.locator('[data-testid="user-menu"]');
this.logoutButton = page.locator('[data-testid="logout-button"]');
}
async expectLoaded() {
await expect(this.page).toHaveURL(/.*dashboard/);
await expect(this.welcomeMessage).toBeVisible();
}
async getWelcomeText() {
return await this.welcomeMessage.textContent();
}
async getTableRowCount() {
const rows = this.dataTable.locator('tbody tr');
return await rows.count();
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
}
- ユーティリティ関数
// tests/utils/api-helpers.ts
import fetch from 'node-fetch';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3000/api';
const API_TOKEN = process.env.API_TOKEN || 'test-api-token';
export interface User {
username: string;
email: string;
password: string;
id?: string;
}
export async function createTestUser(userData: User): Promise<User> {
const response = await fetch(`${API_BASE_URL}/test/create-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_TOKEN}`
},
body: JSON.stringify(userData)
});
if (!response.ok) {
throw new Error(`Failed to create test user: ${await response.text()}`);
}
return await response.json();
}
export async function deleteTestUser(userId: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/test/delete-user/${userId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
}
});
if (!response.ok) {
throw new Error(`Failed to delete test user: ${await response.text()}`);
}
}
export async function cleanupAllTestUsers(): Promise<void> {
const response = await fetch(`${API_BASE_URL}/test/cleanup-test-users`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`
}
});
if (!response.ok) {
throw new Error(`Failed to cleanup test users: ${await response.text()}`);
}
}
// tests/utils/test-helpers.ts
import { Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
export async function loginAsTestUser(page: Page): Promise<void> {
const testUser = JSON.parse(process.env.TEST_USER || '{}');
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(testUser.username, testUser.password);
}
- テストケース
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { createTestUser, deleteTestUser } from '../utils/api-helpers';
test.describe('認証機能テスト', () => {
let testUser: { username: string, email: string, password: string, id?: string };
test.beforeAll(async () => {
// テスト固有のユーザーを作成
testUser = await createTestUser({
username: `testuser-${Date.now()}`,
email: `test-${Date.now()}@example.com`,
password: 'Password123'
});
});
test.afterAll(async () => {
// テストで使用したユーザーを削除
if (testUser.id) {
await deleteTestUser(testUser.id);
}
});
test('有効な認証情報でログインできる', async ({ page }) => {
const loginPage = new LoginPage(page);
const dashboardPage = new DashboardPage(page);
await loginPage.goto();
await loginPage.login(testUser.username, testUser.password);
// ダッシュボードが表示されることを検証
await dashboardPage.expectLoaded();
const welcomeText = await dashboardPage.getWelcomeText();
expect(welcomeText).toContain(testUser.username);
});
test('無効なパスワードでログインに失敗する', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(testUser.username, 'WrongPassword');
// エラーメッセージが表示されることを検証
await loginPage.expectErrorMessage('Invalid credentials');
// ログインページに留まることを検証
await expect(page).toHaveURL(/.*login/);
});
});
// tests/e2e/dashboard.spec.ts
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../pages/DashboardPage';
import { loginAsTestUser } from '../utils/test-helpers';
test.describe('ダッシュボード機能テスト', () => {
test.beforeEach(async ({ page }) => {
// 各テスト前にログイン
await loginAsTestUser(page);
// ダッシュボードに移動していることを確認
await expect(page).toHaveURL(/.*dashboard/);
});
test('ダッシュボードにデータが表示される', async ({ page }) => {
const dashboardPage = new DashboardPage(page);
// データテーブルが表示されることを検証
await expect(dashboardPage.dataTable).toBeVisible();
// テーブルに少なくとも1行のデータがあることを検証
const rowCount = await dashboardPage.getTableRowCount();
expect(rowCount).toBeGreaterThan(0);
});
test('ログアウトできる', async ({ page }) => {
const dashboardPage = new DashboardPage(page);
await dashboardPage.logout();
// ログインページにリダイレクトされることを検証
await expect(page).toHaveURL(/.*login/);
});
});
E2Eテストのベストプラクティス
テスト設計のパターン
効果的なE2Eテスト設計に役立つパターン:
-
ページオブジェクトモデル(POM)
- UIの各ページを個別のクラスやモジュールとして表現
- 要素のセレクタとページ操作ロジックをカプセル化
- テストコードとページロジックの分離
-
スクリーンプレイパターン
- ユーザーの操作シナリオをスクリプトとして表現
- 一連の操作をステップごとに記述
- POMを拡張し、より複雑なフローをモデル化
例(Playwrightの場合):
class CheckoutFlow {
readonly page: Page;
readonly cartPage: CartPage;
readonly shippingPage: ShippingPage;
readonly paymentPage: PaymentPage;
readonly confirmationPage: ConfirmationPage;
constructor(page: Page) {
this.page = page;
this.cartPage = new CartPage(page);
this.shippingPage = new ShippingPage(page);
this.paymentPage = new PaymentPage(page);
this.confirmationPage = new ConfirmationPage(page);
}
async completeCheckout(
shippingDetails: ShippingDetails,
paymentDetails: PaymentDetails
) {
// カートページで購入を進める
await this.cartPage.proceedToCheckout();
// 配送情報の入力
await this.shippingPage.fillDetails(shippingDetails);
await this.shippingPage.continue();
// 支払い情報の入力
await this.paymentPage.fillDetails(paymentDetails);
await this.paymentPage.submitPayment();
// 確認ページのアサーション
await this.confirmationPage.expectOrderConfirmed();
return await this.confirmationPage.getOrderNumber();
}
}
-
テストデータビルダー
- テストデータを柔軟に構築するためのパターン
- チェーンメソッドでデータを段階的に構築
- デフォルト値と上書き機能を提供
例:
class UserBuilder {
private userData: any = {
username: 'defaultuser',
email: 'default@example.com',
password: 'DefaultPass123',
firstName: 'Default',
lastName: 'User',
role: 'user'
};
withUsername(username: string): UserBuilder {
this.userData.username = username;
return this;
}
withEmail(email: string): UserBuilder {
this.userData.email = email;
return this;
}
withPassword(password: string): UserBuilder {
this.userData.password = password;
return this;
}
withRole(role: string): UserBuilder {
this.userData.role = role;
return this;
}
asAdmin(): UserBuilder {
this.userData.role = 'admin';
return this;
}
build(): any {
return { ...this.userData };
}
}
// 使用例
const regularUser = new UserBuilder()
.withUsername('testuser')
.withEmail('test@example.com')
.build();
const adminUser = new UserBuilder()
.withUsername('adminuser')
.withEmail('admin@example.com')
.asAdmin()
.build();
-
ステートマシンパターン
- アプリケーションの状態遷移をモデル化
- 状態間の遷移を明示的に定義
- 無効な状態遷移を防止
-
コマンドパターン
- 一連の操作をコマンドとしてカプセル化
- 複雑な操作を再利用可能なコンポーネントとして定義
- 操作の繰り返しを効率化
ページオブジェクトモデル(POM)
ページオブジェクトモデル(POM)は、UI要素とそれに関連する操作をカプセル化するオブジェクト指向のパターンです。
-
POMの主要な利点
- テストの保守性を向上
- コードの再利用性を高める
- テストの可読性を向上
- UI変更の影響を局所化
-
POMの構造
- 各ページ/コンポーネントを個別のクラスとして表現
- セレクタの定義を一箇所に集約
- ページに対する操作をメソッドとして定義
- ページ間の遷移を明示的に表現
-
POMの実装例(TypeScript + Playwright)
基本的なページオブジェクト:
// BasePage.ts
import { Page, Locator } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
readonly url: string;
constructor(page: Page, url: string) {
this.page = page;
this.url = url;
}
async goto() {
await this.page.goto(this.url);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
}
// LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { DashboardPage } from './DashboardPage';
export class LoginPage extends BasePage {
// セレクタ
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page, '/login');
this.usernameInput = page.locator('[data-testid="username-input"]');
this.passwordInput = page.locator('[data-testid="password-input"]');
this.loginButton = page.locator('[data-testid="login-button"]');
this.errorMessage = page.locator('[data-testid="login-error"]');
}
// アクション
async fillUsername(username: string) {
await this.usernameInput.fill(username);
}
async fillPassword(password: string) {
await this.passwordInput.fill(password);
}
async clickLoginButton() {
await this.loginButton.click();
}
// 高レベル操作
async login(username: string, password: string): Promise<DashboardPage> {
await this.fillUsername(username);
await this.fillPassword(password);
await this.clickLoginButton();
return new DashboardPage(this.page);
}
// アサーション補助
async getErrorText(): Promise<string | null> {
if (await this.errorMessage.isVisible()) {
return await this.errorMessage.textContent();
}
return null;
}
}
-
コンポジション vs 継承
- 継承:基本的な機能を共有するために使用(BasePage)
- コンポジション:共通コンポーネント(ヘッダー、フッターなど)を組み込むために使用
コンポジションの例:
// HeaderComponent.ts
import { Page, Locator } from '@playwright/test';
export class HeaderComponent {
readonly page: Page;
readonly userMenu: Locator;
readonly logoutButton: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
this.userMenu = page.locator('[data-testid="user-menu"]');
this.logoutButton = page.locator('[data-testid="logout-button"]');
this.searchInput = page.locator('[data-testid="search-input"]');
}
async logout() {
await this.userMenu.click();
await this.logoutButton.click();
}
async search(keyword: string) {
await this.searchInput.fill(keyword);
await this.page.keyboard.press('Enter');
}
}
// DashboardPage.ts(コンポジションを使用)
import { Page, Locator } from '@playwright/test';
import { BasePage } from './BasePage';
import { HeaderComponent } from './components/HeaderComponent';
export class DashboardPage extends BasePage {
readonly header: HeaderComponent;
readonly dashboardTitle: Locator;
readonly dataTable: Locator;
constructor(page: Page) {
super(page, '/dashboard');
this.header = new HeaderComponent(page);
this.dashboardTitle = page.locator('h1.dashboard-title');
this.dataTable = page.locator('[data-testid="data-table"]');
}
// ヘッダーのメソッドに委譲
async logout() {
await this.header.logout();
}
async search(keyword: string) {
await this.header.search(keyword);
}
}
-
POM使用のベストプラクティス
- セレクタをページクラス内に集約する
- 単一責任の原則に従う(1ページ/コンポーネント = 1クラス)
- ページ遷移を明示的に返す
- 複雑なロジックをページオブジェクト内にカプセル化する
- テスト固有のロジックはテストファイルに残す
フレームワーク選定のポイント
E2Eテストフレームワーク選定の主要な検討ポイント:
-
プロジェクト要件の評価
- 技術スタック: 開発チームが使用しているプログラミング言語と親和性
- テスト対象: Webアプリ、モバイルアプリ、またはその両方
- ブラウザサポート: サポートが必要なブラウザとバージョン
- プラットフォーム: 実行環境(Windows、Mac、Linux、CI/CD)
-
主要評価基準
評価基準 | 説明 | 重要度 |
---|---|---|
学習曲線 | チームが新しいツールを習得しやすいか | 高 |
実行速度 | テスト実行の速度とパフォーマンス | 高 |
安定性 | テストの信頼性と不安定性(フレイキネス)の低さ | 非常に高 |
コミュニティ | コミュニティサポートとリソースの豊富さ | 中~高 |
ドキュメント | 公式ドキュメントの質と量 | 高 |
デバッグ機能 | 問題特定のためのツールと機能 | 高 |
CI/CD統合 | 継続的インテグレーション環境との統合性 | 高 |
拡張性 | カスタムニーズに合わせて拡張できるか | 中 |
コスト | 商用ライセンスが必要か、サポートコスト | 中~高 |
- フレームワーク比較のための評価マトリックス
以下の表は、主要なフレームワークを異なる観点から評価しています:
評価基準 | Selenium | Cypress | Playwright | TestCafe | Puppeteer |
---|---|---|---|---|---|
言語サポート | 多言語 | JavaScript | 多言語 | JavaScript | JavaScript |
学習曲線 | 中~高 | 低~中 | 中 | 低 | 中 |
設定の容易さ | 中 | 高 | 高 | 非常に高 | 高 |
ブラウザサポート | 広範囲 | 制限あり | 主要ブラウザ | 広範囲 | Chromiumのみ |
実行速度 | 中 | 高 | 高 | 高 | 高 |
並行実行 | サードパーティ | 組み込み | 組み込み | 組み込み | カスタム |
自動待機 | 手動実装 | 自動 | 自動 | 自動 | 手動実装 |
デバッグ機能 | 中 | 優れている | 優れている | 良好 | 中 |
レポート機能 | サードパーティ | 組み込み | 組み込み | 組み込み | サードパーティ |
CI/CD統合 | 広範囲 | 良好 | 良好 | 良好 | 中 |
コミュニティ | 非常に大きい | 大きい | 成長中 | 中 | 大きい |
モバイルサポート | Appiumと連携 | 制限あり | エミュレーション | 制限あり | エミュレーション |
-
選定プロセスのステップ
- チームのスキルセットとプロジェクト要件の評価
- 複数の候補フレームワークでのプロトタイプ作成
- 実際のプロジェクトに近い小規模のテストケースで評価
- 保守性とスケーラビリティの検討
- デバッグと問題解決のしやすさを検証
- チームフィードバックの収集と最終決定
-
使用シナリオ別の推奨フレームワーク
シナリオ | 推奨フレームワーク | 理由 |
---|---|---|
多言語環境 | Selenium, Playwright | 複数の言語をサポート |
Webとモバイル | Appium + Selenium | 両方のプラットフォームをカバー |
SPA中心のプロジェクト | Cypress, Playwright | 優れた自動待機と安定性 |
簡単な導入が必要 | Cypress, TestCafe | 最小限の設定で開始可能 |
既存のJestテスト | Playwright | Jestと同様の構文と統合 |
テスト実行の高速化テクニック
E2Eテストの実行時間を短縮するためのテクニック:
-
テスト分割と並列実行
- テストスイートを小さなバッチに分割
- 複数のワーカー/スレッドで並列実行
- CI/CDプラットフォームの並行ジョブ機能を活用
Playwrightでの並列実行設定例:
// playwright.config.ts import { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { workers: process.env.CI ? 4 : undefined, // CI環境では4並列で実行 fullyParallel: true, // テストの完全並列実行を有効化 projects: [ { name: 'chromium', use: { browserName: 'chromium' } }, { name: 'firefox', use: { browserName: 'firefox' } }, { name: 'webkit', use: { browserName: 'webkit' } }, ], }; export default config;
GitHubアクションでの並列ジョブ設定例:
jobs: test: strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v3 - name: Run Tests run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
-
テストの独立性確保
- 各テストが他のテストに依存しないようにする
- テスト間でのデータ共有を避ける
- 各テストが自身の前提条件を設定する
-
ブラウザの再利用
- テスト間でブラウザコンテキストを再利用
- ブラウザの起動・終了回数を減らす
- 状態をリセットしながらコンテキストを維持
Cypressでの例:
// cypress.config.js module.exports = { e2e: { experimentalSessionAndOrigin: true, // セッションを維持しながらテスト間の独立性を確保 } } // テスト内でのセッション再利用 it('test with preserved login', () => { cy.session('login-session', () => { // ログイン処理 cy.visit('/login'); cy.get('#username').type('testuser'); cy.get('#password').type('password123'); cy.get('#login-button').click(); cy.url().should('include', '/dashboard'); }); // このテストでは新しいブラウザを起動せずにログイン状態を再利用 cy.visit('/dashboard'); cy.get('.user-info').should('contain', 'testuser'); });
-
APIを活用した状態設定
- UI操作ではなくAPIを使用して前提条件を設定
- バックエンドAPIを直接呼び出してデータを準備
- 認証をUI経由ではなくAPIで行う
Playwrightの例:
test.beforeEach(async ({ page, request }) => { // APIを使用してログイン状態を作成 const loginResponse = await request.post('/api/login', { data: { username: 'testuser', password: 'password123' } }); // レスポンスからトークンを取得 const { token } = await loginResponse.json(); // トークンをブラウザのストレージに設定 await page.goto('/'); await page.evaluate(token => { localStorage.setItem('auth_token', token); }, token); // ダッシュボードに直接アクセス(ログインUIをスキップ) await page.goto('/dashboard'); });
-
テストケースの最適化
- 重複するテストケースを排除
- クリティカルパスのみをテスト
- テストケースの優先順位付け
-
リソースの最適化
- ヘッドレスモードの活用
- 画像・ビデオ・フォントのロードを無効化
- ネットワークリクエストのモック化
Cypressでのリソース最適化例:
// cypress.config.js module.exports = { e2e: { setupNodeEvents(on, config) { on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome') { // Chrome起動オプションの最適化 launchOptions.args.push('--disable-gpu'); launchOptions.args.push('--disable-dev-shm-usage'); launchOptions.args.push('--disable-extensions'); launchOptions.args.push('--disable-web-security'); return launchOptions; } }); }, }, };
Playwrightでのネットワークリクエストのモック例:
test('外部APIをモックしてテストを高速化', async ({ page }) => { // 特定のAPIリクエストをインターセプトしてモックレスポンスを返す await page.route('**/api/slow-external-service', route => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data: 'mocked-response' }) }); }); await page.goto('/dashboard'); // テストコード });