You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

network-first.md 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. # Network-First Safeguards
  2. ## Principle
  3. Register network interceptions **before** any navigation or user action. Store the interception promise and await it immediately after the triggering step. Replace implicit waits with deterministic signals based on network responses, spinner disappearance, or event hooks.
  4. ## Rationale
  5. The most common source of flaky E2E tests is **race conditions** between navigation and network interception:
  6. - Navigate then intercept = missed requests (too late)
  7. - No explicit wait = assertion runs before response arrives
  8. - Hard waits (`waitForTimeout(3000)`) = slow, unreliable, brittle
  9. Network-first patterns provide:
  10. - **Zero race conditions**: Intercept is active before triggering action
  11. - **Deterministic waits**: Wait for actual response, not arbitrary timeouts
  12. - **Actionable failures**: Assert on response status/body, not generic "element not found"
  13. - **Speed**: No padding with extra wait time
  14. ## Pattern Examples
  15. ### Example 1: Intercept Before Navigate Pattern
  16. **Context**: The foundational pattern for all E2E tests. Always register route interception **before** the action that triggers the request (navigation, click, form submit).
  17. **Implementation**:
  18. ```typescript
  19. // ✅ CORRECT: Intercept BEFORE navigate
  20. test('user can view dashboard data', async ({ page }) => {
  21. // Step 1: Register interception FIRST
  22. const usersPromise = page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 200);
  23. // Step 2: THEN trigger the request
  24. await page.goto('/dashboard');
  25. // Step 3: THEN await the response
  26. const usersResponse = await usersPromise;
  27. const users = await usersResponse.json();
  28. // Step 4: Assert on structured data
  29. expect(users).toHaveLength(10);
  30. await expect(page.getByText(users[0].name)).toBeVisible();
  31. });
  32. // Cypress equivalent
  33. describe('Dashboard', () => {
  34. it('should display users', () => {
  35. // Step 1: Register interception FIRST
  36. cy.intercept('GET', '**/api/users').as('getUsers');
  37. // Step 2: THEN trigger
  38. cy.visit('/dashboard');
  39. // Step 3: THEN await
  40. cy.wait('@getUsers').then((interception) => {
  41. // Step 4: Assert on structured data
  42. expect(interception.response.statusCode).to.equal(200);
  43. expect(interception.response.body).to.have.length(10);
  44. cy.contains(interception.response.body[0].name).should('be.visible');
  45. });
  46. });
  47. });
  48. // ❌ WRONG: Navigate BEFORE intercept (race condition!)
  49. test('flaky test example', async ({ page }) => {
  50. await page.goto('/dashboard'); // Request fires immediately
  51. const usersPromise = page.waitForResponse('/api/users'); // TOO LATE - might miss it
  52. const response = await usersPromise; // May timeout randomly
  53. });
  54. ```
  55. **Key Points**:
  56. - Playwright: Use `page.waitForResponse()` with URL pattern or predicate **before** `page.goto()` or `page.click()`
  57. - Cypress: Use `cy.intercept().as()` **before** `cy.visit()` or `cy.click()`
  58. - Store promise/alias, trigger action, **then** await response
  59. - This prevents 95% of race-condition flakiness in E2E tests
  60. ### Example 2: HAR Capture for Debugging
  61. **Context**: When debugging flaky tests or building deterministic mocks, capture real network traffic with HAR files. Replay them in tests for consistent, offline-capable test runs.
  62. **Implementation**:
  63. ```typescript
  64. // playwright.config.ts - Enable HAR recording
  65. export default defineConfig({
  66. use: {
  67. // Record HAR on first run
  68. recordHar: { path: './hars/', mode: 'minimal' },
  69. // Or replay HAR in tests
  70. // serviceWorkers: 'block',
  71. },
  72. });
  73. // Capture HAR for specific test
  74. test('capture network for order flow', async ({ page, context }) => {
  75. // Start recording
  76. await context.routeFromHAR('./hars/order-flow.har', {
  77. url: '**/api/**',
  78. update: true, // Update HAR with new requests
  79. });
  80. await page.goto('/checkout');
  81. await page.fill('[data-testid="credit-card"]', '4111111111111111');
  82. await page.click('[data-testid="submit-order"]');
  83. await expect(page.getByText('Order Confirmed')).toBeVisible();
  84. // HAR saved to ./hars/order-flow.har
  85. });
  86. // Replay HAR for deterministic tests (no real API needed)
  87. test('replay order flow from HAR', async ({ page, context }) => {
  88. // Replay captured HAR
  89. await context.routeFromHAR('./hars/order-flow.har', {
  90. url: '**/api/**',
  91. update: false, // Read-only mode
  92. });
  93. // Test runs with exact recorded responses - fully deterministic
  94. await page.goto('/checkout');
  95. await page.fill('[data-testid="credit-card"]', '4111111111111111');
  96. await page.click('[data-testid="submit-order"]');
  97. await expect(page.getByText('Order Confirmed')).toBeVisible();
  98. });
  99. // Custom mock based on HAR insights
  100. test('mock order response based on HAR', async ({ page }) => {
  101. // After analyzing HAR, create focused mock
  102. await page.route('**/api/orders', (route) =>
  103. route.fulfill({
  104. status: 200,
  105. contentType: 'application/json',
  106. body: JSON.stringify({
  107. orderId: '12345',
  108. status: 'confirmed',
  109. total: 99.99,
  110. }),
  111. }),
  112. );
  113. await page.goto('/checkout');
  114. await page.click('[data-testid="submit-order"]');
  115. await expect(page.getByText('Order #12345')).toBeVisible();
  116. });
  117. ```
  118. **Key Points**:
  119. - HAR files capture real request/response pairs for analysis
  120. - `update: true` records new traffic; `update: false` replays existing
  121. - Replay mode makes tests fully deterministic (no upstream API needed)
  122. - Use HAR to understand API contracts, then create focused mocks
  123. ### Example 3: Network Stub with Edge Cases
  124. **Context**: When testing error handling, timeouts, and edge cases, stub network responses to simulate failures. Test both happy path and error scenarios.
  125. **Implementation**:
  126. ```typescript
  127. // Test happy path
  128. test('order succeeds with valid data', async ({ page }) => {
  129. await page.route('**/api/orders', (route) =>
  130. route.fulfill({
  131. status: 200,
  132. contentType: 'application/json',
  133. body: JSON.stringify({ orderId: '123', status: 'confirmed' }),
  134. }),
  135. );
  136. await page.goto('/checkout');
  137. await page.click('[data-testid="submit-order"]');
  138. await expect(page.getByText('Order Confirmed')).toBeVisible();
  139. });
  140. // Test 500 error
  141. test('order fails with server error', async ({ page }) => {
  142. // Listen for console errors (app should log gracefully)
  143. const consoleErrors: string[] = [];
  144. page.on('console', (msg) => {
  145. if (msg.type() === 'error') consoleErrors.push(msg.text());
  146. });
  147. // Stub 500 error
  148. await page.route('**/api/orders', (route) =>
  149. route.fulfill({
  150. status: 500,
  151. contentType: 'application/json',
  152. body: JSON.stringify({ error: 'Internal Server Error' }),
  153. }),
  154. );
  155. await page.goto('/checkout');
  156. await page.click('[data-testid="submit-order"]');
  157. // Assert UI shows error gracefully
  158. await expect(page.getByText('Something went wrong')).toBeVisible();
  159. await expect(page.getByText('Please try again')).toBeVisible();
  160. // Verify error logged (not thrown)
  161. expect(consoleErrors.some((e) => e.includes('Order failed'))).toBeTruthy();
  162. });
  163. // Test network timeout
  164. test('order times out after 10 seconds', async ({ page }) => {
  165. // Stub delayed response (never resolves within timeout)
  166. await page.route(
  167. '**/api/orders',
  168. (route) => new Promise(() => {}), // Never resolves - simulates timeout
  169. );
  170. await page.goto('/checkout');
  171. await page.click('[data-testid="submit-order"]');
  172. // App should show timeout message after configured timeout
  173. await expect(page.getByText('Request timed out')).toBeVisible({ timeout: 15000 });
  174. });
  175. // Test partial data response
  176. test('order handles missing optional fields', async ({ page }) => {
  177. await page.route('**/api/orders', (route) =>
  178. route.fulfill({
  179. status: 200,
  180. contentType: 'application/json',
  181. // Missing optional fields like 'trackingNumber', 'estimatedDelivery'
  182. body: JSON.stringify({ orderId: '123', status: 'confirmed' }),
  183. }),
  184. );
  185. await page.goto('/checkout');
  186. await page.click('[data-testid="submit-order"]');
  187. // App should handle gracefully - no crash, shows what's available
  188. await expect(page.getByText('Order Confirmed')).toBeVisible();
  189. await expect(page.getByText('Tracking information pending')).toBeVisible();
  190. });
  191. // Cypress equivalents
  192. describe('Order Edge Cases', () => {
  193. it('should handle 500 error', () => {
  194. cy.intercept('POST', '**/api/orders', {
  195. statusCode: 500,
  196. body: { error: 'Internal Server Error' },
  197. }).as('orderFailed');
  198. cy.visit('/checkout');
  199. cy.get('[data-testid="submit-order"]').click();
  200. cy.wait('@orderFailed');
  201. cy.contains('Something went wrong').should('be.visible');
  202. });
  203. it('should handle timeout', () => {
  204. cy.intercept('POST', '**/api/orders', (req) => {
  205. req.reply({ delay: 20000 }); // Delay beyond app timeout
  206. }).as('orderTimeout');
  207. cy.visit('/checkout');
  208. cy.get('[data-testid="submit-order"]').click();
  209. cy.contains('Request timed out', { timeout: 15000 }).should('be.visible');
  210. });
  211. });
  212. ```
  213. **Key Points**:
  214. - Stub different HTTP status codes (200, 400, 500, 503)
  215. - Simulate timeouts with `delay` or non-resolving promises
  216. - Test partial/incomplete data responses
  217. - Verify app handles errors gracefully (no crashes, user-friendly messages)
  218. ### Example 4: Deterministic Waiting
  219. **Context**: Never use hard waits (`waitForTimeout(3000)`). Always wait for explicit signals: network responses, element state changes, or custom events.
  220. **Implementation**:
  221. ```typescript
  222. // ✅ GOOD: Wait for response with predicate
  223. test('wait for specific response', async ({ page }) => {
  224. const responsePromise = page.waitForResponse((resp) => resp.url().includes('/api/users') && resp.status() === 200);
  225. await page.goto('/dashboard');
  226. const response = await responsePromise;
  227. expect(response.status()).toBe(200);
  228. await expect(page.getByText('Dashboard')).toBeVisible();
  229. });
  230. // ✅ GOOD: Wait for multiple responses
  231. test('wait for all required data', async ({ page }) => {
  232. const usersPromise = page.waitForResponse('**/api/users');
  233. const productsPromise = page.waitForResponse('**/api/products');
  234. const ordersPromise = page.waitForResponse('**/api/orders');
  235. await page.goto('/dashboard');
  236. // Wait for all in parallel
  237. const [users, products, orders] = await Promise.all([usersPromise, productsPromise, ordersPromise]);
  238. expect(users.status()).toBe(200);
  239. expect(products.status()).toBe(200);
  240. expect(orders.status()).toBe(200);
  241. });
  242. // ✅ GOOD: Wait for spinner to disappear
  243. test('wait for loading indicator', async ({ page }) => {
  244. await page.goto('/dashboard');
  245. // Wait for spinner to disappear (signals data loaded)
  246. await expect(page.getByTestId('loading-spinner')).not.toBeVisible();
  247. await expect(page.getByText('Dashboard')).toBeVisible();
  248. });
  249. // ✅ GOOD: Wait for custom event (advanced)
  250. test('wait for custom ready event', async ({ page }) => {
  251. let appReady = false;
  252. page.on('console', (msg) => {
  253. if (msg.text() === 'App ready') appReady = true;
  254. });
  255. await page.goto('/dashboard');
  256. // Poll until custom condition met
  257. await page.waitForFunction(() => appReady, { timeout: 10000 });
  258. await expect(page.getByText('Dashboard')).toBeVisible();
  259. });
  260. // ❌ BAD: Hard wait (arbitrary timeout)
  261. test('flaky hard wait example', async ({ page }) => {
  262. await page.goto('/dashboard');
  263. await page.waitForTimeout(3000); // WHY 3 seconds? What if slower? What if faster?
  264. await expect(page.getByText('Dashboard')).toBeVisible(); // May fail if >3s
  265. });
  266. // Cypress equivalents
  267. describe('Deterministic Waiting', () => {
  268. it('should wait for response', () => {
  269. cy.intercept('GET', '**/api/users').as('getUsers');
  270. cy.visit('/dashboard');
  271. cy.wait('@getUsers').its('response.statusCode').should('eq', 200);
  272. cy.contains('Dashboard').should('be.visible');
  273. });
  274. it('should wait for spinner to disappear', () => {
  275. cy.visit('/dashboard');
  276. cy.get('[data-testid="loading-spinner"]').should('not.exist');
  277. cy.contains('Dashboard').should('be.visible');
  278. });
  279. // ❌ BAD: Hard wait
  280. it('flaky hard wait', () => {
  281. cy.visit('/dashboard');
  282. cy.wait(3000); // NEVER DO THIS
  283. cy.contains('Dashboard').should('be.visible');
  284. });
  285. });
  286. ```
  287. **Key Points**:
  288. - `waitForResponse()` with URL pattern or predicate = deterministic
  289. - `waitForLoadState('networkidle')` = wait for all network activity to finish
  290. - Wait for element state changes (spinner disappears, button enabled)
  291. - **NEVER** use `waitForTimeout()` or `cy.wait(ms)` - always non-deterministic
  292. ### Example 5: Anti-Pattern - Navigate Then Mock
  293. **Problem**:
  294. ```typescript
  295. // ❌ BAD: Race condition - mock registered AFTER navigation starts
  296. test('flaky test - navigate then mock', async ({ page }) => {
  297. // Navigation starts immediately
  298. await page.goto('/dashboard'); // Request to /api/users fires NOW
  299. // Mock registered too late - request already sent
  300. await page.route('**/api/users', (route) =>
  301. route.fulfill({
  302. status: 200,
  303. body: JSON.stringify([{ id: 1, name: 'Test User' }]),
  304. }),
  305. );
  306. // Test randomly passes/fails depending on timing
  307. await expect(page.getByText('Test User')).toBeVisible(); // Flaky!
  308. });
  309. // ❌ BAD: No wait for response
  310. test('flaky test - no explicit wait', async ({ page }) => {
  311. await page.route('**/api/users', (route) => route.fulfill({ status: 200, body: JSON.stringify([]) }));
  312. await page.goto('/dashboard');
  313. // Assertion runs immediately - may fail if response slow
  314. await expect(page.getByText('No users found')).toBeVisible(); // Flaky!
  315. });
  316. // ❌ BAD: Generic timeout
  317. test('flaky test - hard wait', async ({ page }) => {
  318. await page.goto('/dashboard');
  319. await page.waitForTimeout(2000); // Arbitrary wait - brittle
  320. await expect(page.getByText('Dashboard')).toBeVisible();
  321. });
  322. ```
  323. **Why It Fails**:
  324. - **Mock after navigate**: Request fires during navigation, mock isn't active yet (race condition)
  325. - **No explicit wait**: Assertion runs before response arrives (timing-dependent)
  326. - **Hard waits**: Slow tests, brittle (fails if < timeout, wastes time if > timeout)
  327. - **Non-deterministic**: Passes locally, fails in CI (different speeds)
  328. **Better Approach**: Always intercept → trigger → await
  329. ```typescript
  330. // ✅ GOOD: Intercept BEFORE navigate
  331. test('deterministic test', async ({ page }) => {
  332. // Step 1: Register mock FIRST
  333. await page.route('**/api/users', (route) =>
  334. route.fulfill({
  335. status: 200,
  336. contentType: 'application/json',
  337. body: JSON.stringify([{ id: 1, name: 'Test User' }]),
  338. }),
  339. );
  340. // Step 2: Store response promise BEFORE trigger
  341. const responsePromise = page.waitForResponse('**/api/users');
  342. // Step 3: THEN trigger
  343. await page.goto('/dashboard');
  344. // Step 4: THEN await response
  345. await responsePromise;
  346. // Step 5: THEN assert (data is guaranteed loaded)
  347. await expect(page.getByText('Test User')).toBeVisible();
  348. });
  349. ```
  350. **Key Points**:
  351. - Order matters: Mock → Promise → Trigger → Await → Assert
  352. - No race conditions: Mock is active before request fires
  353. - Explicit wait: Response promise ensures data loaded
  354. - Deterministic: Always passes if app works correctly
  355. ## Integration Points
  356. - **Used in workflows**: `*atdd` (test generation), `*automate` (test expansion), `*framework` (network setup)
  357. - **Related fragments**:
  358. - `fixture-architecture.md` - Network fixture patterns
  359. - `data-factories.md` - API-first setup with network
  360. - `test-quality.md` - Deterministic test principles
  361. ## Debugging Network Issues
  362. When network tests fail, check:
  363. 1. **Timing**: Is interception registered **before** action?
  364. 2. **URL pattern**: Does pattern match actual request URL?
  365. 3. **Response format**: Is mocked response valid JSON/format?
  366. 4. **Status code**: Is app checking for 200 vs 201 vs 204?
  367. 5. **HAR file**: Capture real traffic to understand actual API contract
  368. ```typescript
  369. // Debug network issues with logging
  370. test('debug network', async ({ page }) => {
  371. // Log all requests
  372. page.on('request', (req) => console.log('→', req.method(), req.url()));
  373. // Log all responses
  374. page.on('response', (resp) => console.log('←', resp.status(), resp.url()));
  375. await page.goto('/dashboard');
  376. });
  377. ```
  378. _Source: Murat Testing Philosophy (lines 94-137), Playwright network patterns, Cypress intercept best practices._