2년 3개월간의 회사 생활을 마친 뒤 요즘 본격적으로 구직 활동에 집중하고 있습니다. 면접을 보기도 하고 과제나 코딩 테스트도 치르곤 하는데 과제를 제출하고 나면 항상 아쉬움이 남더라구요... 그래서 제출 후 다시 볼 수 없는 경우가 아니라면 과제는 한 번 더 풀어보면서 복습하는 편입니다. 처음 과제를 풀 때 놓쳤던 부분이나 더 나은 문제 해결 방식을 찾는 과정에서 인사이트를 얻고 있어요.
특히 이전에 풀었던 몇몇 과제들은 단위 테스트(Unit Test)나 E2E(End-to-End) 테스트 코드가 포함되어 있어, 다시 풀어본 본 후에 검증해볼 수 있었는데요. 최근 한 과제에서 다시 풀어본 과제의 테스트가 실패했다는 결과를 확인했음에도 불구하고 제 로컬 환경에서는 문제가 발생하지 않아 원인을 찾는 데 어려움을 겪기도 했습니다.
이 포스팅에서는 테스트에 오류가 발생한 오류의 원인을 파악하고 디버깅해나가는 과정에 대해 자세히 다뤄보고자 합니다. Playwright나 MSW, Service Worker에 대한 기본적인 설명은 생략하고 디버깅 과정과 오류 해결에 초점을 맞추어 관련 라이브러리의 내부 동작에 대해서만 담을 예정입니다.
(과제의 세부 내용은 공개할 수 없기 때문에 디버깅을 위한 예제를 별도로 만들어 검증해 보겠습니다.
테스트는 Playwright와 MSW를 사용하고 있습니다.)
검증을 위해 임의로 아래와 같이 개발용, 테스트용 mock data를 정의해보겠습니다.
// api
await axios.get<Test>("/api/test");
// 개발 mock data
[
{ id: 1, title: '나는 개발용이야' }
]
// 테스트 mock data
[
{ id: 2, title: '나는 테스트용이야' }
]
보다 효율적인 디버깅을 위해 UI 모드로 테스트를 실행하겠습니다.
(--debug 모드로 할 수도 있지만 저는 UI 모드가 편하더라구요.)
npx playwright test --ui
UI 모드에서는 테스트 중 오류가 발생했을 때 Errors 탭을 통해 오류 메시지를 확인할 수 있습니다.
제 경우에는 테스트 코드가 요구한 '나는 테스트용이야' 메시지가 아닌 '나는 개발용이야' 메시지가 표시되어 timeout error가 발생했던 것이 원인이었습니다 🥲.
원인을 분석하기 위해 먼저 테스트 코드에서 요구하는 '나는 테스트용이야' 텍스트가 출력되는 조건과 관련된 테스트 코드를 살펴보겠습니다.
// tests/test.ts
test.beforeEach(async ({ worker }) => {
await worker.use(...[테스트용 핸들러 목록]));
});
- 테스트가 실행되기 전 테스트용 mock 데이터를 반환하는 핸들러를 추가합니다.
- 여기서 beforeEach는 각 테스트가 실행되기전 실행하는 hook입니다.
// tests/test.ts
test.describe("테스트1", () => {
test("나는 테스트용이야 텍스트 확인", async ({ page, worker }) => {
await page.goto("/");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("나는 테스트용이야")).toBeVisible();
...
});
})
- 페이지에 진입하면 `/api/test GET` 요청이 발생하고 응답으로 받아온 텍스트가 화면에 표시됩니다.
- 이때 화면에 표시되는 텍스트가 실제로 "나는 테스트용이야"인지 확인하는 과정이 포함됩니다.
앞서 살펴본 내용을 기반으로 가장 의심스러웠던 부분은 worker에 핸들러가 정상적으로 등록되지 않았을 가능성입니다. 핸들러가 올바르게 설정되지 않았다면 테스트 실행 시 예상했던 응답이 제대로 반환되지 않아 원하는 텍스트가 화면에 표시되지 않을 수 있다고 생각했습니다. 이러한 가능성을 확인하기 위해 몇 가지 가설을 설정하고 이를 점검해 보겠습니다.
beforeEach가 정상적으로 동작했는지 내부에 console 출력
// tests/test.ts
test.beforeEach(async ({ worker }) => {
console.log('✅ 함수 실행됨!')
await worker.use(...[테스트용 핸들러 목록])`);
});
page.on('response')
이벤트 리스너를 사용해서 API 요청이 테스트용 mock data를 응답 받았는지 확인
page.on('response', async (response) => {
if (response.url().includes('/api/test')) {
try {
const json = await response.json();
console.log(`응답 데이터:`, json);
} catch (error) {
console.log(`JSON 변환 실패:`, error);
}
}
});
/api/test GET
요청은 개발용 moking data를 반환실제로 /api/test 요청은 테스트용 mock 데이터가 아닌 개발용 mock 데이터를 반환하고 있었습니다.
그렇다면 worker에 handler가 등록되지 않은걸까요?
이 과정에서 console 탭에서 아래와 같은 에러 로그를 추가로 발견하게 됩니다.
위 에러는 worker.start()가 중복으로 호출되었다는 의미로 이미 MSW(Mock Service Worker)가 활성화된 상태라면 해당 호출을 제거해도 된다는 뜻입니다.
따라서 중복 호출이 실제 문제의 원인인지 추가적인 확인이 필요했습니다.
worker.start()가 호출되고 있는 곳
import { setupWorker } from 'msw/browser'
useEffect(() => {
const worker = setupWorker(...[개발용 핸들러]);
await worker.start();
}, []);
해당 프로젝트는 테스트 환경뿐 아니라 개발 환경에서도 MSW를 사용하고 있었기 때문에 최상위 컴포넌트의 useEffect에서 worker.start()를 호출하고 있었습니다.
IDE를 통해 worker.start()를 검색해봐도 오직 이 곳만 검색이 되는데요.
이 부분을 주석 처리하고 다시 테스트를 실행해 보겠습니다.
worker.start()를 주석 처리, 다시 테스트 실행
네... worker.start()를 주석 처리 하니 테스트가 정상적으로 통과했습니다.
문제가 해결된 듯했지만 디버깅 과정에서 이해가 되지 않은 부분이 남아있습니다.
테스트 상황에서 worker.start() 호출로 인해 테스트용 핸들러가 아닌 개발용 handler가 적용된 이유는 무엇인지,
테스트용 핸들러를 정상적으로 적용해도 개발용 handler가 덮어씌워지는 것인지에 대한 추가적인 확인이 필요했습니다.
이 가설을 살펴보기 위해서는 worker.start()가 어떻게 동작하는지, 각각 전달된 handler가 내부적으로 어떻게 관리되는지를 확인할 필요가 있다고 생각했습니다.
지금부터 내부 동작을 조금 더 자세히 살펴보겠습니다.
MSW는 브라우저와 Node.js 환경을 위한 API 모킹 라이브러리입니다.
MSW를 사용하면 네트워크 요청을 가로채고(intercept) 관찰하며 정의된 모의 응답(mock response)을 제공할 수 있습니다.
브라우저 환경에서 API 모킹이 가능하도록 client-worker 통신 채널을 설정합니다.
import { setupWorker } from 'msw/browser'
import handlers from './handlers'
const worker = setupWorker(...handlers)
위 예시 코드와 같이 setupWorker에 여러 개의 handler를 스프레드 문법(...)으로 전달할 수 있습니다.
setupWorker() 함수는 현재 브라우저 환경에서 API 모킹을 제어하는 데 사용할 수 있는 객체인 Worker 인스턴스를 반환합니다.
생성된 Worker는 다양한 메서드를 제공합니다. 특히 우리가 살펴볼 대상인 start() 메서드 또한 포함하고 있습니다.
이제 본격적으로 worker.start()가 어떻게 동작하는지 확인해 보겠습니다.
worker.start()를 호출하면 Service Worker를 등록하고 reqeust를 가로채기 시작합니다.
기본으로 서비스 워커 스크립트가 특정 경로(./mockServiceWorker.js)에서 제공되며 필요 시 사용자가 원하는 custom 경로를 옵션으로 지정할 수 있습니다.
worker.start({
serviceWorker: {
url: '/assets/mockServiceWorker.js', //custom path
},
})
createStartHandler
// msw/createStartHandler.ts
export const createStartHandler = (
context: SetupWorkerInternalContext,
): StartHandler => {
return function start(options, customOptions) {
...
// Handle requests signaled by the worker.
context.workerChannel.on(
'REQUEST',
createRequestListener(context, options),
)
// Handle responses signaled by the worker.
context.workerChannel.on('RESPONSE', createResponseListener(context))
...
}
}
createRequestListener
export const createRequestListener = (
context: SetupWorkerInternalContext,
options: RequiredDeep<StartOptions>,
) => {
return async (
event: MessageEvent,
message: ServiceWorkerMessage<
'REQUEST',
ServiceWorkerIncomingEventsMap['REQUEST']
>,
) => {
...
await handleRequest(
...
)
...
}
}
즉, worker.start()가 실행되면 Service Worker request를 가로채고 내부에서는 해당 요청을 처리할 수 있는 핸들러를 찾고 처리한 결과를 다시 Service Worker로 되돌려줍니다.
그렇다면 Playwright를 통해 MSW를 사용할 때 실제로 요청에 대한 핸들러가 전달되는 과정은 어떻게 이루어질까요? 이에 대한 흐름을 살펴보겠습니다.
playwright-msw는 Playwright 테스트 환경에서 API 요청을 가로채고 모의 응답(mock response)을 제공할 수 있게 해주는 라이브러리입니다.
Playwright의 기본 테스트 기능을 확장하는 방식으로 API 요청을 가로채는 Mock Server를 설정할 수 있습니다.
createWorkerFixture()는 테스트가 실행될 때마다 새로운 모킹 환경을 설정하는 Fixture를 반환하는 함수입니다. (공식 설명)
playwright의 기본 테스트 기능을 확장해, API 요청을 가로채는 Mock Server를 설정할 수 있습니다.
createWorkerFixture 함수에 handlers(테스트용)를 전달합니다.
*️⃣ Fixture
Fixture는 Playwright Test에서 각 테스트가 실행되기 전에 필요한 환경을 설정하고 테스트 종료 후 정리(clean up)까지 자동으로 수행하는 역할을 합니다. (공식 설명)
// 사용 예시
import { test as base, expect } from '@playwright/test';
import { http } from 'msw';
import type { MockServiceWorker } from 'playwright-msw';
import { createWorkerFixture } from 'playwright-msw';
import handlers from './handlers';
const test = base.extend<{
worker: MockServiceWorker;
http: typeof http;
}>({
worker: createWorkerFixture(handlers),
http
});
export { expect, test };
// playwright-msw/lib/fixture.ts
const createWorkerFixture = (handlers = [], config) => [
({ page }, use) => __awaiter(void 0, void 0, void 0, function* () {
const worker = yield (0, worker_1.createWorker)(page, handlers, config);
...
];
exports.createWorkerFixture = createWorkerFixture;
const createWorker = (page, requestHandlers, config) => __awaiter(void 0, void 0, void 0, function* () {
const router = new router_1.Router(page, requestHandlers, config);
yield router.start();
return {
use: (...handlers) => __awaiter(void 0, void 0, void 0, function* () { return router.use(...handlers); }),
resetHandlers: (...handlers) => __awaiter(void 0, void 0, void 0, function* () { return router.resetHandlers(...handlers); }),
resetCookieStore: () => {
cookies_1.store.clear();
},
};
});
exports.createWor ker = createWorker;
class Router {
...
start() {
return __awaiter(this, void 0, void 0, function* () {
...
for (const initialHandler of this.initialRequestHandlers) {
yield this.registerMswHandler(initialHandler);
}
...
});
}
...
}
class Router {
...
registerMswHandler(handler) {
return __awaiter(this, void 0, void 0, function* () {
const path = (0, utils_1.getHandlerPath)(handler, this.config);
...
this.setRouteData({
path,
routeHandler: yield this.registerPlaywrightRoute(path),
requestHandlers: [handler],
});
...
});
}
...
}
class Router {
...
registerPlaywrightRoute(path) {
return __awaiter(this, void 0, void 0, function* () {
const routeHandler = (route) => {
...
return (0, handler_1.handleRoute)(route, requestHandlers);
};
yield this.page.route((0, utils_1.convertMswPathToPlaywrightUrl)(path), routeHandler);
...
});
}
...
}
...
const handleRoute = (route, handlers) => __awaiter(void 0, void 0, void 0, function* () {
...
yield (0, msw_1.handleRequest)(...)
...
}
...
결국 우리가 전달한 테스트용 핸들러들은 plarywright-msw의 Router 인스턴스 내부에서 관리되며 MSW의 handleRequest를 통해 Playwright 페이지의 요청을 적절히 처리하고 모킹된 응답을 제공하는 것이죠.
handleRequest는 요청(request)을 받아 적절한 응답(response)을 결정하는 핵심 로직입니다.
전달받은
매개변수로 받은 핸들러중에서 요청을 처리할 핸들러가 있는지 확인하고 핸들러가 존재하면 onMockedResponse 함수를 호출하여 응답을 처리합니다.
즉 playwright-msw와 MSW 둘 다 내부에서 handleRequest를 호출하고 있지만 handleRequest는 내부에서 핸들러를 저장하고 관리하는 것이 아니라 전달받은 핸들러로 요청을 처리하는 역할만 합니다. 결국 서로 독립적으로 핸들러를 관리하기 때문에 위에서 세웠던 가설3 마저 아닌 것으로 확인했습니다.
MSW와 다르게 Service Worker를 사용하지 않고 오직 Playwright의 단독 기능(page.route)으로만 요청을 가로챕니다.
중요한 점은 msw와 다르게 playwright는 page.route로 요청을 가로 챈다는 건데요 page.route를 알아보는 도중 공식홈페이지(page.route)에서 중요한 글을 발견하게 됩니다.
공식홈페이지(page.route)
NOTE
page.route() will not intercept requests intercepted by Service Worker. See this issue. We recommend disabling Service Workers when using request interception by setting serviceWorkers to 'block'.
Playwright 공식 문서에 따르면 Service Worker가 request를 가로채면 playwright의 page.route는 해당 request를 볼 수 없어 가로챌 수 없다는 것입니다...
눈으로 직접 확인하기 위해 Service Worker가 등록됐을때와 아닐 때 console.log를 통해 출력해 보겠습니다.
async _onRoute(route) {
console.log('가로챈 요청 URL:', route.request().url())
...
}
위 코드는 page.route가 요청을 가로 챘을때 실행되는 callback 함수입니다.
Service Worker가 등록되지 않았을 때(msw의 worker.start()를 주석 처리)
Service Worker가 등록됐을 때(msw의 worker.start() 실행)
결국 Service Worker가 request를 가로채면 Playwright의 page.route는 해당 request를 볼 수 없어 가로채지 못하고 등록된 handler를 반환할 수 없는 것이죠.
그럼 어떻게 이 상황을 해결할 수 있을까요?
테스트 환경에서 Service Worker를 등록하지 않기 위해 항상 worker.start()를 주석처리 해야 하는 걸까요?
테스트 환경에서 항상 worker.start()를 비활성화 할 필요는 없습니다. 대신 상황에 따라 조건부로 분기 처리하거나 Playwright의 설정을 활용하여 해결할 수 있습니다.
// package.json
{
...
scripts: {
"dev:test": "TEST=true"
}
...
}
if (typeof window !== 'undefiend' || process.env.TEST === 'true') {
const worker = setupWorker(...handlers);
worker.start();
}
webServer
테스트를 실행하기 전에 로컬 개발 서버를 실행할 수 있는 옵션입니다.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Run your local dev server before starting the tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
command
url
reuseExistingServer
use
브라우저 종류, 뷰포트 크기, 로그인 상태, 쿠키 등 테스트 환경을 미리 세팅할 수 있는 옵션입니다.
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// Base URL to use in actions like `await page.goto('/')`.
baseURL: 'http://localhost:3000',
},
});
baseUrl
package.json에 테스트 환경을 위한 환경변수를 설정 (방법1과 동일)
webServer 설정을 통해 별도 포트로 개발 서버를 실행
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run dev:test', // 여기를 수정
url: 'http://localhost:4000',
reuseExistingServer: !process.env.CI,
},
});
use.baseUrl을 webServer.url과 동일하게 설정
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'http://localhost:4000',
},
});
실행된 개발서버를 종료하지 않아도 됨
Plarywright 테스트를 실행
사실 진행했던 과제에는 이미 해결 방법1이 적용되어 있었습니다. 그리고 PR을 올린 후 CI 환경에서 자동으로 테스트가 실행될 때에는 문제가 되지 않습니다. (즉 제출에는 아무 문제가 없었습니다.)
다만 로컬에서 테스트를 실행하고 싶었던 저는 README.md 파일만 참고하여 테스트 실행 방법을 확인했고 별도로 package.json 파일을 확인하지 않다 보니 테스트 전용 로컬 서버를 실행하는 스크립트가 있다는 점을 미처 인지하지 못했습니다. 그 결과 로컬 서버를 종료하지 않은 상태에서 테스트를 실행하여 문제가 발생했던 것이죠 🥲
이미 Playwright를 활용해 테스트 하는 것에 능숙했다면 조금 더 쉽게 원인을 파악하고 해결할 수 있었겠지만 아직 Playwright 사용이 능숙하지 않았던 저에게는 라이브러리 내부를 살펴볼 수 있는 좋은 경험이었던 거 같습니다.
짧은 시간 내에 과제를 수행해야 하는 상황에서는 이러한 간단한 문제로 인해 혼란을 겪을 수 있다고 생각합니다. 저와 비슷한 경험을 하시는 다른 분들이 이 글을 통해 빠르게 원인을 파악하고 해결하실 수 있었으면 좋겠습니다.