選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

fixture-architecture.md 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. # Fixture Architecture Playbook
  2. ## Principle
  3. Build test helpers as pure functions first, then wrap them in framework-specific fixtures. Compose capabilities using `mergeTests` (Playwright) or layered commands (Cypress) instead of inheritance. Each fixture should solve one isolated concern (auth, API, logs, network).
  4. ## Rationale
  5. Traditional Page Object Models create tight coupling through inheritance chains (`BasePage → LoginPage → AdminPage`). When base classes change, all descendants break. Pure functions with fixture wrappers provide:
  6. - **Testability**: Pure functions run in unit tests without framework overhead
  7. - **Composability**: Mix capabilities freely via `mergeTests`, no inheritance constraints
  8. - **Reusability**: Export fixtures via package subpaths for cross-project sharing
  9. - **Maintainability**: One concern per fixture = clear responsibility boundaries
  10. ## Pattern Examples
  11. ### Example 1: Pure Function → Fixture Pattern
  12. **Context**: When building any test helper, always start with a pure function that accepts all dependencies explicitly. Then wrap it in a Playwright fixture or Cypress command.
  13. **Implementation**:
  14. ```typescript
  15. // playwright/support/helpers/api-request.ts
  16. // Step 1: Pure function (ALWAYS FIRST!)
  17. type ApiRequestParams = {
  18. request: APIRequestContext;
  19. method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  20. url: string;
  21. data?: unknown;
  22. headers?: Record<string, string>;
  23. };
  24. export async function apiRequest({
  25. request,
  26. method,
  27. url,
  28. data,
  29. headers = {}
  30. }: ApiRequestParams) {
  31. const response = await request.fetch(url, {
  32. method,
  33. data,
  34. headers: {
  35. 'Content-Type': 'application/json',
  36. ...headers
  37. }
  38. });
  39. if (!response.ok()) {
  40. throw new Error(`API request failed: ${response.status()} ${await response.text()}`);
  41. }
  42. return response.json();
  43. }
  44. // Step 2: Fixture wrapper
  45. // playwright/support/fixtures/api-request-fixture.ts
  46. import { test as base } from '@playwright/test';
  47. import { apiRequest } from '../helpers/api-request';
  48. export const test = base.extend<{ apiRequest: typeof apiRequest }>({
  49. apiRequest: async ({ request }, use) => {
  50. // Inject framework dependency, expose pure function
  51. await use((params) => apiRequest({ request, ...params }));
  52. }
  53. });
  54. // Step 3: Package exports for reusability
  55. // package.json
  56. {
  57. "exports": {
  58. "./api-request": "./playwright/support/helpers/api-request.ts",
  59. "./api-request/fixtures": "./playwright/support/fixtures/api-request-fixture.ts"
  60. }
  61. }
  62. ```
  63. **Key Points**:
  64. - Pure function is unit-testable without Playwright running
  65. - Framework dependency (`request`) injected at fixture boundary
  66. - Fixture exposes the pure function to test context
  67. - Package subpath exports enable `import { apiRequest } from 'my-fixtures/api-request'`
  68. ### Example 2: Composable Fixture System with mergeTests
  69. **Context**: When building comprehensive test capabilities, compose multiple focused fixtures instead of creating monolithic helper classes. Each fixture provides one capability.
  70. **Implementation**:
  71. ```typescript
  72. // playwright/support/fixtures/merged-fixtures.ts
  73. import { test as base, mergeTests } from '@playwright/test';
  74. import { test as apiRequestFixture } from './api-request-fixture';
  75. import { test as networkFixture } from './network-fixture';
  76. import { test as authFixture } from './auth-fixture';
  77. import { test as logFixture } from './log-fixture';
  78. // Compose all fixtures for comprehensive capabilities
  79. export const test = mergeTests(base, apiRequestFixture, networkFixture, authFixture, logFixture);
  80. export { expect } from '@playwright/test';
  81. // Example usage in tests:
  82. // import { test, expect } from './support/fixtures/merged-fixtures';
  83. //
  84. // test('user can create order', async ({ page, apiRequest, auth, network }) => {
  85. // await auth.loginAs('customer@example.com');
  86. // await network.interceptRoute('POST', '**/api/orders', { id: 123 });
  87. // await page.goto('/checkout');
  88. // await page.click('[data-testid="submit-order"]');
  89. // await expect(page.getByText('Order #123')).toBeVisible();
  90. // });
  91. ```
  92. **Individual Fixture Examples**:
  93. ```typescript
  94. // network-fixture.ts
  95. export const test = base.extend({
  96. network: async ({ page }, use) => {
  97. const interceptedRoutes = new Map();
  98. const interceptRoute = async (method: string, url: string, response: unknown) => {
  99. await page.route(url, (route) => {
  100. if (route.request().method() === method) {
  101. route.fulfill({ body: JSON.stringify(response) });
  102. }
  103. });
  104. interceptedRoutes.set(`${method}:${url}`, response);
  105. };
  106. await use({ interceptRoute });
  107. // Cleanup
  108. interceptedRoutes.clear();
  109. },
  110. });
  111. // auth-fixture.ts
  112. export const test = base.extend({
  113. auth: async ({ page, context }, use) => {
  114. const loginAs = async (email: string) => {
  115. // Use API to setup auth (fast!)
  116. const token = await getAuthToken(email);
  117. await context.addCookies([
  118. {
  119. name: 'auth_token',
  120. value: token,
  121. domain: 'localhost',
  122. path: '/',
  123. },
  124. ]);
  125. };
  126. await use({ loginAs });
  127. },
  128. });
  129. ```
  130. **Key Points**:
  131. - `mergeTests` combines fixtures without inheritance
  132. - Each fixture has single responsibility (network, auth, logs)
  133. - Tests import merged fixture and access all capabilities
  134. - No coupling between fixtures—add/remove freely
  135. ### Example 3: Framework-Agnostic HTTP Helper
  136. **Context**: When building HTTP helpers, keep them framework-agnostic. Accept all params explicitly so they work in unit tests, Playwright, Cypress, or any context.
  137. **Implementation**:
  138. ```typescript
  139. // shared/helpers/http-helper.ts
  140. // Pure, framework-agnostic function
  141. type HttpHelperParams = {
  142. baseUrl: string;
  143. endpoint: string;
  144. method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  145. body?: unknown;
  146. headers?: Record<string, string>;
  147. token?: string;
  148. };
  149. export async function makeHttpRequest({ baseUrl, endpoint, method, body, headers = {}, token }: HttpHelperParams): Promise<unknown> {
  150. const url = `${baseUrl}${endpoint}`;
  151. const requestHeaders = {
  152. 'Content-Type': 'application/json',
  153. ...(token && { Authorization: `Bearer ${token}` }),
  154. ...headers,
  155. };
  156. const response = await fetch(url, {
  157. method,
  158. headers: requestHeaders,
  159. body: body ? JSON.stringify(body) : undefined,
  160. });
  161. if (!response.ok) {
  162. const errorText = await response.text();
  163. throw new Error(`HTTP ${method} ${url} failed: ${response.status} ${errorText}`);
  164. }
  165. return response.json();
  166. }
  167. // Playwright fixture wrapper
  168. // playwright/support/fixtures/http-fixture.ts
  169. import { test as base } from '@playwright/test';
  170. import { makeHttpRequest } from '../../shared/helpers/http-helper';
  171. export const test = base.extend({
  172. httpHelper: async ({}, use) => {
  173. const baseUrl = process.env.API_BASE_URL || 'http://localhost:3000';
  174. await use((params) => makeHttpRequest({ baseUrl, ...params }));
  175. },
  176. });
  177. // Cypress command wrapper
  178. // cypress/support/commands.ts
  179. import { makeHttpRequest } from '../../shared/helpers/http-helper';
  180. Cypress.Commands.add('apiRequest', (params) => {
  181. const baseUrl = Cypress.env('API_BASE_URL') || 'http://localhost:3000';
  182. return cy.wrap(makeHttpRequest({ baseUrl, ...params }));
  183. });
  184. ```
  185. **Key Points**:
  186. - Pure function uses only standard `fetch`, no framework dependencies
  187. - Unit tests call `makeHttpRequest` directly with all params
  188. - Playwright and Cypress wrappers inject framework-specific config
  189. - Same logic runs everywhere—zero duplication
  190. ### Example 4: Fixture Cleanup Pattern
  191. **Context**: When fixtures create resources (data, files, connections), ensure automatic cleanup in fixture teardown. Tests must not leak state.
  192. **Implementation**:
  193. ```typescript
  194. // playwright/support/fixtures/database-fixture.ts
  195. import { test as base } from '@playwright/test';
  196. import { seedDatabase, deleteRecord } from '../helpers/db-helpers';
  197. type DatabaseFixture = {
  198. seedUser: (userData: Partial<User>) => Promise<User>;
  199. seedOrder: (orderData: Partial<Order>) => Promise<Order>;
  200. };
  201. export const test = base.extend<DatabaseFixture>({
  202. seedUser: async ({}, use) => {
  203. const createdUsers: string[] = [];
  204. const seedUser = async (userData: Partial<User>) => {
  205. const user = await seedDatabase('users', userData);
  206. createdUsers.push(user.id);
  207. return user;
  208. };
  209. await use(seedUser);
  210. // Auto-cleanup: Delete all users created during test
  211. for (const userId of createdUsers) {
  212. await deleteRecord('users', userId);
  213. }
  214. createdUsers.length = 0;
  215. },
  216. seedOrder: async ({}, use) => {
  217. const createdOrders: string[] = [];
  218. const seedOrder = async (orderData: Partial<Order>) => {
  219. const order = await seedDatabase('orders', orderData);
  220. createdOrders.push(order.id);
  221. return order;
  222. };
  223. await use(seedOrder);
  224. // Auto-cleanup: Delete all orders
  225. for (const orderId of createdOrders) {
  226. await deleteRecord('orders', orderId);
  227. }
  228. createdOrders.length = 0;
  229. },
  230. });
  231. // Example usage:
  232. // test('user can place order', async ({ seedUser, seedOrder, page }) => {
  233. // const user = await seedUser({ email: 'test@example.com' });
  234. // const order = await seedOrder({ userId: user.id, total: 100 });
  235. //
  236. // await page.goto(`/orders/${order.id}`);
  237. // await expect(page.getByText('Order Total: $100')).toBeVisible();
  238. //
  239. // // No manual cleanup needed—fixture handles it automatically
  240. // });
  241. ```
  242. **Key Points**:
  243. - Track all created resources in array during test execution
  244. - Teardown (after `use()`) deletes all tracked resources
  245. - Tests don't manually clean up—happens automatically
  246. - Prevents test pollution and flakiness from shared state
  247. ### Anti-Pattern: Inheritance-Based Page Objects
  248. **Problem**:
  249. ```typescript
  250. // ❌ BAD: Page Object Model with inheritance
  251. class BasePage {
  252. constructor(public page: Page) {}
  253. async navigate(url: string) {
  254. await this.page.goto(url);
  255. }
  256. async clickButton(selector: string) {
  257. await this.page.click(selector);
  258. }
  259. }
  260. class LoginPage extends BasePage {
  261. async login(email: string, password: string) {
  262. await this.navigate('/login');
  263. await this.page.fill('#email', email);
  264. await this.page.fill('#password', password);
  265. await this.clickButton('#submit');
  266. }
  267. }
  268. class AdminPage extends LoginPage {
  269. async accessAdminPanel() {
  270. await this.login('admin@example.com', 'admin123');
  271. await this.navigate('/admin');
  272. }
  273. }
  274. ```
  275. **Why It Fails**:
  276. - Changes to `BasePage` break all descendants (`LoginPage`, `AdminPage`)
  277. - `AdminPage` inherits unnecessary `login` details—tight coupling
  278. - Cannot compose capabilities (e.g., admin + reporting features require multiple inheritance)
  279. - Hard to test `BasePage` methods in isolation
  280. - Hidden state in class instances leads to unpredictable behavior
  281. **Better Approach**: Use pure functions + fixtures
  282. ```typescript
  283. // ✅ GOOD: Pure functions with fixture composition
  284. // helpers/navigation.ts
  285. export async function navigate(page: Page, url: string) {
  286. await page.goto(url);
  287. }
  288. // helpers/auth.ts
  289. export async function login(page: Page, email: string, password: string) {
  290. await page.fill('[data-testid="email"]', email);
  291. await page.fill('[data-testid="password"]', password);
  292. await page.click('[data-testid="submit"]');
  293. }
  294. // fixtures/admin-fixture.ts
  295. export const test = base.extend({
  296. adminPage: async ({ page }, use) => {
  297. await login(page, 'admin@example.com', 'admin123');
  298. await navigate(page, '/admin');
  299. await use(page);
  300. },
  301. });
  302. // Tests import exactly what they need—no inheritance
  303. ```
  304. ## Integration Points
  305. - **Used in workflows**: `*atdd` (test generation), `*automate` (test expansion), `*framework` (initial setup)
  306. - **Related fragments**:
  307. - `data-factories.md` - Factory functions for test data
  308. - `network-first.md` - Network interception patterns
  309. - `test-quality.md` - Deterministic test design principles
  310. ## Helper Function Reuse Guidelines
  311. When deciding whether to create a fixture, follow these rules:
  312. - **3+ uses** → Create fixture with subpath export (shared across tests/projects)
  313. - **2-3 uses** → Create utility module (shared within project)
  314. - **1 use** → Keep inline (avoid premature abstraction)
  315. - **Complex logic** → Factory function pattern (dynamic data generation)
  316. _Source: Murat Testing Philosophy (lines 74-122), enterprise production patterns, Playwright fixture docs._