Worker 를 로컬에서 테스트하기

CloudflareBeginner
지금 연습하기

소개

지원 API 가 올바른 요청은 처리하지만 잘못된 JSON 을 받으면 중단됩니다. 이 문제 보고를 실패하는 테스트로 만들고, 파싱 경계를 수정한 다음 검증 및 업스트림 오류 처리를 유지하도록 테스트 모음을 확장합니다.

이 독립 VM 에는 Build a Support Request API 에서 다룬 라우팅 개념을 기반으로 한 작은 API 가 준비되어 있으며, 의도적인 회귀 버그가 하나 포함되어 있습니다. Node.js 22.22.0, Wrangler 4.131.1, Miniflare 4.20260730.0 이 /home/labex/project/worker-tests에 준비되어 있습니다. Node 의 내장 테스트 러너를 사용해 직접 테스트를 작성하고 Miniflare 를 통해 workerd 에서 핸들러를 실행합니다. 기본 JavaScript 지식과 이전 API 실습이 필요합니다. 테스트 어설션, 훅, 픽스처 격리는 이 실습에서 설명합니다.

이 실습은 로컬에서만 실행되는 실습입니다. Cloudflare 계정 인증, 원격 리소스 또는 이전 VM 이 필요하지 않습니다. 모든 Worker 외부 요청은 로컬 픽스처가 가로채며, 각 테스트가 끝나면 테스트 런타임을 정리합니다.

Workers 런타임을 대상으로 테스트 작성하기

이 단계에서는 제공된 지원 API 를 대상으로 작은 테스트 모음을 구성합니다. 테스트는 Node 의 테스트 러너에서 실행하지만, 요청은 핸들러를 Node 에 직접 가져오는 대신 Miniflare 의 workerd 런타임 내부에서 실행됩니다.

독립 프로젝트로 이동한 후 제공된 핸들러와 고정된 의존성을 확인합니다.

cd /home/labex/project/worker-tests
node --version
npx wrangler --version
npm ls miniflare --depth=0
cat src/index.js

Node v22.22.0, Wrangler 4.131.1, 직접 의존성으로 설치된 Miniflare 4.20260730.0 이 표시되어야 합니다. 이 도구들은 미리 설치되어 있습니다. 자신의 컴퓨터에서 실행할 때는 npm install --save-dev miniflare@4.20260730.0으로 정확한 테스트 의존성을 추가하고, 기존 lockfile 이 있으면 npm ci를 사용합니다. 이 실습에서는 계속 바뀌는 최신 태그에 의존하지 않고 4.x API 와 지원되는 호환성 날짜를 의도적으로 고정합니다.

test/support.test.mjs를 만듭니다. 따옴표로 감싼 heredoc 은 모듈을 있는 그대로 기록합니다. test는 테스트 케이스를 선언하고, assert.equal은 단일 값을 검사하며, assert.deepEqual은 구조화된 JSON 을 비교합니다. 각 비동기 테스트 케이스는 응답을 받은 후 어설션을 실행합니다.

beforeEach는 새 런타임을 시작하고 호출 목록을 초기화합니다. afterEach는 테스트가 실패하더라도 런타임을 정리합니다. dispatchFetch는 프로세스 내부에서 테스트 요청을 보냅니다. 이때 사용하는 호스트 이름은 배포된 Worker 를 의미하지 않습니다. outboundService는 Worker 의 모든 fetch 를 가로채 로컬 픽스처 응답을 반환하며, 인터넷으로 트래픽을 전달하지 않습니다. 의도한 업스트림 URL 과 메서드를 검사하고 정규화된 요청을 기록합니다. cf: false는 샘플 Cloudflare 요청 메타데이터를 가져오지 않도록 설정합니다. 로그인, 원격 바인딩 또는 클라우드 배포는 사용하지 않습니다.

cat > test/support.test.mjs <<'JS'
import {test, beforeEach, afterEach} from 'node:test';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import {Miniflare} from 'miniflare';

let mf;
let calls;
beforeEach(() => {
  calls = [];
  mf = new Miniflare({
    modules: true,
    scriptPath: fileURLToPath(new URL('../src/index.js', import.meta.url)),
    compatibilityDate: '2026-07-30',
    cf: false,
    bindings: {UPSTREAM_URL: 'https://tickets.test'},
    outboundService: async (request) => {
      assert.equal(request.url, 'https://tickets.test/tickets');
      assert.equal(request.method, 'POST');
      const body = await request.json();
      calls.push(body);
      if (body.subject === 'simulate-outage') {
        return new Response('SIMULATED_INTERNAL_DETAIL', {status: 503});
      }
      return Response.json({ticket: `demo-${calls.length}`, subject: body.subject}, {status: 201});
    }
  });
});
afterEach(async () => { await mf.dispose(); });

test('health stays public', async () => {
  const response = await mf.dispatchFetch('http://worker.test/health');
  assert.equal(response.status, 200);
  assert.deepEqual(await response.json(), {status: 'ok'});
  assert.equal(calls.length, 0);
});

test('valid request reaches the local fixture', async () => {
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({subject: '  Printer offline  '})
  });
  assert.equal(response.status, 201);
  assert.deepEqual(await response.json(), {ticket: 'demo-1', subject: 'Printer offline'});
  assert.deepEqual(calls, [{subject: 'Printer offline'}]);
});
JS

표준 Node 테스트 명령을 실행합니다. --test-reporter=spec은 읽기 쉬운 테스트 케이스 이름과 전체 결과를 출력합니다.

node --test --test-reporter=spec test/support.test.mjs

통과하는 테스트 2 개와 실패 0 개가 표시되어야 합니다. health 요청은 업스트림을 호출하지 않아야 합니다. 올바른 요청은 demo-1을 반환하고, 앞뒤 공백이 제거된 subject를 정확히 한 번 전송해야 합니다. 아직 잘못된 JSON 은 테스트하지 않습니다. 검증을 통해 테스트 모음과 독립적인 런타임 동작을 확인합니다.

기반 인터페이스는 Miniflare APIoutbound service 옵션을 참고합니다. 프로덕션 네트워크 동작은 별도의 배포 테스트가 필요합니다.

실패하는 회귀 테스트 추가하기

이 단계에서는 보고된 결함을 테스트로 고정합니다. 잘못된 JSON 은 예측 가능한 400 응답을 반환해야 하지만, 시작 핸들러는 파싱 예외를 그대로 밖으로 내보냅니다.

>>를 사용해 테스트 하나를 추가합니다. 기존 테스트 2 개는 그대로 유지됩니다. 요청 본문은 완성되지 않은 JSON 텍스트 {입니다. 어설션은 JSON 을 파싱하기 전에 응답 상태를 검사하므로, 실패 원인이 HTTP 계약 위반임을 명확히 보여 줍니다.

cat >> test/support.test.mjs <<'JS'

test('malformed JSON returns 400 before the upstream', async () => {
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: '{'
  });
  assert.equal(response.status, 400);
  assert.deepEqual(await response.json(), {error: 'invalid_json'});
  assert.equal(calls.length, 0);
});
JS
node --test --test-reporter=spec test/support.test.mjs

테스트 3 개가 실행되고, 2 개는 통과하며 잘못된 JSON 테스트는 실패해야 합니다. 어설션에는 실제 값 500 과 예상 값 400 의 차이가 표시되고, 프로세스는 0 이 아닌 종료 코드로 끝납니다. 런타임이 내부 파싱 예외를 추가로 출력할 수도 있습니다. 이것이 의도한 결함이며, 예상 상태 코드를 500 으로 바꿀 이유가 아닙니다. import 오류, 패키지 누락 또는 기존 테스트 실패는 별개의 문제입니다.

핸들러에서 보호되지 않은 await request.json()을 확인합니다. 잘못된 JSON 요청은 픽스처에 도달하면 안 됩니다. 결함이 남아 있는 동안 검증을 실행합니다. 이 단계의 목적은 회귀 테스트가 실패하고 기존 테스트는 통과하는지 확인하는 것입니다. 다음 단계에서 구현을 수정합니다.

테스트를 약화하지 않고 JSON 파싱 수정하기

이 단계에서는 JSON 파싱 실패만 처리하고, 기존 라우팅, 검증 및 업스트림 처리는 유지합니다. 핸들러를 다음의 완전한 수정 버전으로 교체합니다. request.json() 주변의 try/catch는 구문 예외를 HTTP 400 상태의 JSON 응답으로 변환합니다. 별도의 업스트림 try/catch는 네트워크 또는 응답 오류를 계속 처리합니다.

cat > src/index.js <<'JS'
export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    if (path !== '/health' && path !== '/requests') {
      return Response.json({error: 'not_found'}, {status: 404});
    }
    const allowed = path === '/health' ? 'GET' : 'POST';
    if (request.method !== allowed) {
      return Response.json({error: 'method_not_allowed'}, {
        status: 405, headers: {Allow: allowed}
      });
    }
    if (path === '/health') return Response.json({status: 'ok'});
    const mediaType = (request.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
    if (mediaType !== 'application/json') {
      return Response.json({error: 'unsupported_media_type'}, {status: 415});
    }
    let body;
    try {
      body = await request.json();
    } catch {
      return Response.json({error: 'invalid_json'}, {status: 400});
    }
    if (!body || Array.isArray(body) || typeof body.subject !== 'string' ||
        body.subject.trim().length < 1 || body.subject.trim().length > 80) {
      return Response.json({error: 'invalid_subject'}, {status: 422});
    }
    const subject = body.subject.trim();
    try {
      const upstream = await fetch(`${env.UPSTREAM_URL}/tickets`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({subject})
      });
      if (!upstream.ok) {
        return Response.json({error: 'upstream_unavailable'}, {status: 502});
      }
      const ticket = await upstream.json();
      return Response.json({ticket: ticket.ticket, subject}, {status: 201});
    } catch {
      return Response.json({error: 'upstream_unavailable'}, {status: 502});
    }
  }
};
JS
node --test --test-reporter=spec test/support.test.mjs

변경하지 않은 잘못된 JSON 테스트를 포함해 3 개 테스트가 모두 통과해야 합니다. 새 테스트 프로세스에서도 통과하는지 확인하기 위해 같은 명령을 다시 실행합니다.

node --test --test-reporter=spec test/support.test.mjs

두 실행 모두 통과 3 개와 실패 0 개를 출력해야 합니다. 각 테스트 케이스는 새 런타임과 비어 있는 픽스처 호출 목록을 받습니다. 테스트를 비활성화하거나 500 응답을 허용해 테스트 모음을 통과시키지 마십시오. 검증은 학습자가 작성한 테스트와 별도의 런타임 응답 집합을 모두 확인합니다.

경계 테스트를 확장하고 로컬에서 마무리하기

이 단계에서는 두 가지 추가 회귀를 방지합니다. 잘못된 입력이 의존성에 도달하는 문제와 업스트림 장애가 성공적인 요청처럼 보이는 문제입니다. 앞의 3 개 테스트는 삭제하지 않고 다음 테스트를 추가합니다.

첫 번째 테스트는 잘못된 JSON 값들을 반복하며 422 를 확인하고, 지원하지 않는 텍스트 입력에 대해서는 415 를 확인합니다. 어느 요청도 업스트림을 호출하면 안 됩니다. 두 번째 테스트는 비어 있는 픽스처에서 시작해 의존성이 503 응답을 반환하는 상황을 시뮬레이션하고, API 가 내부 오류를 숨긴 502 JSON 을 반환하는지 확인합니다. 전체 응답 본문을 비교하면 픽스처 내부 진단 정보가 외부로 노출되는 것도 방지할 수 있습니다.

cat >> test/support.test.mjs <<'JS'

test('invalid subjects and media types never reach the upstream', async () => {
  for (const body of [null, [], {}, {subject: 5}, {subject: ' '}, {subject: 'x'.repeat(81)}]) {
    const response = await mf.dispatchFetch('http://worker.test/requests', {
      method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
    });
    assert.equal(response.status, 422);
    assert.deepEqual(await response.json(), {error: 'invalid_subject'});
  }
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST', headers: {'Content-Type': 'text/plain'}, body: 'hello'
  });
  assert.equal(response.status, 415);
  assert.deepEqual(await response.json(), {error: 'unsupported_media_type'});
  assert.equal(calls.length, 0);
});

test('upstream errors are contained with fresh fixture state', async () => {
  assert.equal(calls.length, 0);
  const response = await mf.dispatchFetch('http://worker.test/requests', {
    method: 'POST', headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({subject: 'simulate-outage'})
  });
  assert.equal(response.status, 502);
  assert.deepEqual(await response.json(), {error: 'upstream_unavailable'});
  assert.deepEqual(calls, [{subject: 'simulate-outage'}]);
});
JS
node --test --test-reporter=spec test/support.test.mjs

통과 5 개와 실패 0 개가 표시되어야 합니다. 테스트 모음을 다시 실행합니다. 각 테스트 케이스마다 픽스처 상태가 초기화되므로 demo-1과 기록된 장애 호출 1 개가 계속 동일하게 유지되어야 합니다.

node --test --test-reporter=spec test/support.test.mjs

모든 작업은 로컬에서 수행되었습니다. 테스트 URL 은 Miniflare 로 전달했고, 모든 Worker 외부 호출은 가로챘으며, 각 런타임은 정리했습니다. VM 에 저장된 Cloudflare 로그인이 없는지 확인합니다.

npx wrangler whoami --json

"loggedIn": false가 표시되어야 합니다. 인증되지 않은 상태 명령은 0 이 아닌 종료 코드로 끝날 수 있습니다. 이 실습에서는 로그인하지 마십시오. 삭제할 클라우드 리소스도 없습니다. 최종 검증을 실행하면 실제 로컬 API 를 확인하고, 수정된 코드뿐 아니라 의도적으로 결함을 넣은 임시 복사본도 테스트가 거부하는지 확인합니다. 이러한 평가용 복사본은 프로젝트를 수정하지 않습니다. 그런 다음 VM 을 종료합니다.

로컬 런타임 테스트를 사용하면 회귀를 재현할 수 있습니다. 하지만 계정 소유권, 배포 설정, 실제 인터넷 의존성 또는 엣지 배포 동작은 검증하지 않습니다. 이러한 항목은 과정의 원격 검사에서 별도로 확인해야 합니다.

요약

로컬 Workers 런타임에서 API 를 실행하는 테스트를 작성하고, 500 대 400 회귀를 재현했으며, 예상 계약을 약화하지 않고 구현을 수정했습니다. 입력 오류와 업스트림 오류 테스트를 추가하고, 테스트 케이스 사이에서 픽스처 상태를 초기화했으며, 각 런타임을 정리했습니다. 전체 테스트 모음은 클라우드 인증 정보나 리소스 쓰기 없이 로컬에서 반복 실행할 수 있도록 유지되었습니다.