비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기

2026.09.10·수정 2026.09.13·약 37분·작성: 해비·블로그 소개

비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기

네트워크가 가끔 느리다는 이유로 테스트를 여러 번 실행해 통과를 기다리면 원인이 남습니다. 이 실습에서는 응답과 대기 함수를 주입하고 Promise 완료 순서를 직접 제어해 실패 상황을 매번 같은 순서로 재현합니다. Node.js 기본 테스트 러너만 사용하며 별도 패키지나 외부 서버가 필요하지 않습니다.

학습 목표와 선수 지식

학습 목표는 요청 실패와 HTTP 오류를 구별하고, 재시도 상한 및 대기 간격을 검증하며, 늦게 도착한 응답이 최신 상태를 덮지 못하도록 시험하는 것입니다. 테스트가 버그를 실제로 잡는지도 의도적인 코드 변형으로 확인합니다. 새 테스트 도구의 설치법보다 비동기 상태의 계약과 관찰 지점을 설계하는 데 집중하는 고급 실습입니다.

선수 과정은 Vitest 첫 테스트오류 반환값과 console.error 검증입니다. JavaScript 모듈, async/await, Promise의 resolve/reject를 알아야 합니다. UI에 연결할 때는 React Testing Library의 사용자 행동 검증 개념을 이어서 적용할 수 있습니다. 여기서는 DOM을 사용하지 않아 jsdom이나 브라우저 설치가 필요하지 않습니다.

1. 성공뿐 아니라 실패의 계약부터 정하기

상황 계약 관찰할 결과
200과 유효 JSON 즉시 반환 값, 요청 1회, 대기 없음
502·503·504 또는 fetch의 TypeError 기본 총 3회까지 시도 최대 요청 3회, 대기 100·200ms
401·404·429·500 이 예제에서는 재시도 안 함 HTTP 상태가 있는 오류
200이지만 잘못된 JSON 파싱 실패 전달 SyntaxError와 요청 1회
이미 취소됐거나 대기 중 취소됨 새 요청을 시작하지 않음 기본 취소는 AbortError, 사용자 지정 취소는 signal.reason; 요청 횟수
이전 검색이 더 늦게 끝남 최신 요청만 상태 변경 이전 성공·실패 모두 최신 결과 유지

3회는 최초 요청을 포함한 횟수입니다. 마지막 실패 뒤에는 대기하지 않습니다. 429나 500을 재시도하지 않는 것은 이 실습의 제한된 제품 정책이며 HTTP의 보편 규칙이 아닙니다. 429를 지원하려면 Retry-After와 최대 대기 예산을 별도 계약으로 설계해야 합니다. POST 결제나 주문 생성에는 이 GET 전용 코드를 그대로 확장하지 마세요. 재요청의 중복 효과를 제어할 별도 계약이 필요합니다.

Fetch 표준에서 네트워크 오류와 응답 상태는 별개입니다. fetch가 완료됐다고 HTTP 성공인 것은 아니므로 response.ok를 검사합니다. 이 예제는 fetch 호출에서 발생한 TypeError만 재시도 후보로 삼습니다. 잘못된 URL도 TypeError가 될 수 있으므로 실제 서비스에서는 URL을 사전에 검증하고 전송 계층에서 오류를 더 구체적으로 분류해야 합니다.

2. 실행 가능한 대상 코드 만들기

빈 실습 폴더에 아래 세 파일을 UTF-8로 저장합니다. Node.js 24 환경을 사용하며 이 글은 v24.19.0에서 검증했습니다. package.json이나 npm install은 필요하지 않습니다. .mjs 확장자로 ES 모듈을 명시합니다. getJson에는 fetchImpl과 wait를 주입할 수 있고 기본값은 실제 fetch와 타이머입니다. 테스트에서는 둘 다 가짜 구현을 전달합니다.

testing-advanced.mjs

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

testing-advanced.mjs

export class HttpError extends Error {
  constructor(status) {
    super(`HTTP ${status}`);
    this.status = status;
  }
}

export function abortableWait(
  ms,
  signal,
  { schedule = setTimeout, cancel = clearTimeout } = {},
) {
  return new Promise((resolve, reject) => {
    signal?.throwIfAborted();
    let timer;
    const cleanup = () => {
      cancel(timer);
      signal?.removeEventListener('abort', onAbort);
    };
    const onAbort = () => {
      cleanup();
      reject(signal.reason);
    };
    timer = schedule(() => {
      cleanup();
      resolve();
    }, ms);
    signal?.addEventListener('abort', onAbort, { once: true });
  });
}
export async function getJson(
  url,
  { fetchImpl = fetch, wait = abortableWait, maxAttempts = 3, signal } = {},
) {
  if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 5) {
    throw new RangeError('maxAttempts must be an integer from 1 to 5');
  }
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    signal?.throwIfAborted();
    let response;
    try {
      response = await fetchImpl(url, { method: 'GET', signal });
    } catch (error) {
      signal?.throwIfAborted();
      if (!(error instanceof TypeError) || attempt === maxAttempts) throw error;
    }
    signal?.throwIfAborted();
    if (response) {
      if (response.ok) {
        const data = await response.json();
        signal?.throwIfAborted();
        return data;
      }
      const error = new HttpError(response.status);
      if (![502, 503, 504].includes(response.status) || attempt === maxAttempts) {
        throw error;
      }
    }
    await wait(100 * 2 ** (attempt - 1), signal);
  }
}

export function createLatestLoader(load) {
  let revision = 0;
  let state = { status: 'idle', data: null, error: null };
  return {
    read: () => ({ ...state }),
    async run(query) {
      const current = ++revision;
      state = { status: 'loading', data: null, error: null };
      try {
        const data = await load(query);
        if (current === revision) state = { status: 'success', data, error: null };
      } catch (error) {
        if (current === revision) state = { status: 'error', data: null, error };
      }
    },
  };
}

testing-advanced.mutation.mjs

import { readFile } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';
const source = await readFile(
  new URL('./testing-advanced.mjs', import.meta.url),
  'utf8',
);
const testFile = new URL('./testing-advanced.test.mjs', import.meta.url);
const { fileURLToPath } = await import('node:url');
const mutations = [
  ['HTTP failure swallowed', 'throw error;', 'return null;'],
  ['no stale response guard', 'if (current === revision)', 'if (true)'],
  ['wrong backoff delay', '100 * 2 ** (attempt - 1)', '1'],
  [
    'missing post-parse abort guard',
    'signal?.throwIfAborted();\n        return data;',
    'return data;',
  ],
  ['timer cleanup removed', 'cancel(timer);', ''],
];
for (const [name, before, after] of mutations) {
  assert.ok(source.includes(before));
  const altered = source.replaceAll(before, after);
  const result = spawnSync(process.execPath, ['--test', fileURLToPath(testFile)], {
    encoding: 'utf8',
    env: {
      ...process.env,
      TEST_SUBJECT_URL: 'data:text/javascript,' + encodeURIComponent(altered),
    },
    timeout: 10000,
  });
  if (result.error) throw result.error;
  assert.notEqual(result.status, 0, name + ' was not detected');
  assert.match(result.stdout + result.stderr, /AssertionError|ERR_ASSERTION/);
  console.log('DETECTED ' + name);
}

testing-advanced.test.mjs

import test from 'node:test';
import assert from 'node:assert/strict';
const { getJson, createLatestLoader, abortableWait } = await import(
  process.env.TEST_SUBJECT_URL || './testing-advanced.mjs'
);

function fixture(sequence) {
  let calls = 0;
  const delays = [];
  return {
    fetchImpl: async (_url, options) => {
      assert.equal(options.method, 'GET');
      const item = sequence[calls++];
      if (!item) throw new Error('Unexpected extra request');
      if (item instanceof Error) throw item;
      return item;
    },
    wait: async (ms) => {
      delays.push(ms);
    },
    calls: () => calls,
    delays,
  };
}
const response = (status, body = '{"items":[]}') => new Response(body, { status });
function deferred() {
  let resolve, reject;
  const promise = new Promise((yes, no) => {
    resolve = yes;
    reject = no;
  });
  return { promise, resolve, reject };
}

test('200: one request and no waiting', async () => {
  const f = fixture([response(200, '{"items":["A"]}')]);
  assert.deepEqual(await getJson('/items', f), { items: ['A'] });
  assert.equal(f.calls(), 1);
  assert.deepEqual(f.delays, []);
});
test('503 then network rejection then 200: bounded backoff', async () => {
  const f = fixture([response(503), new TypeError('network failure'), response(200)]);
  assert.deepEqual(await getJson('/items', f), { items: [] });
  assert.equal(f.calls(), 3);
  assert.deepEqual(f.delays, [100, 200]);
});
for (const status of [401, 404, 429, 500]) {
  test(`${status}: not included in the retry policy`, async () => {
    const f = fixture([response(status)]);
    await assert.rejects(getJson('/items', f), (error) => error.status === status);
    assert.equal(f.calls(), 1);
    assert.deepEqual(f.delays, []);
  });
}
test('503 exhaustion: last failure must escape, with no final sleep', async () => {
  const f = fixture([response(503), response(503), response(503)]);
  await assert.rejects(getJson('/items', f), (error) => error.status === 503);
  assert.equal(f.calls(), 3);
  assert.deepEqual(f.delays, [100, 200]);
});
test('maxAttempts=1: never retry a rejected network request', async () => {
  const failure = new TypeError('network failure');
  const f = fixture([failure]);
  await assert.rejects(
    getJson('/items', { ...f, maxAttempts: 1 }),
    (error) => error === failure,
  );
  assert.equal(f.calls(), 1);
  assert.deepEqual(f.delays, []);
});
test('invalid budget: no request', async () => {
  for (const maxAttempts of [0, -1, 1.5, 6, NaN]) {
    const f = fixture([]);
    await assert.rejects(getJson('/items', { ...f, maxAttempts }), RangeError);
    assert.equal(f.calls(), 0);
  }
});
test('invalid JSON on 200: parsing failure is not retried', async () => {
  const f = fixture([response(200, 'invalid-json')]);
  await assert.rejects(getJson('/items', f), SyntaxError);
  assert.equal(f.calls(), 1);
  assert.deepEqual(f.delays, []);
});
test('programming error: not treated as a network failure', async () => {
  const failure = new Error('implementation bug');
  const f = fixture([failure]);
  await assert.rejects(getJson('/items', f), (error) => error === failure);
  assert.equal(f.calls(), 1);
});
test('already aborted: no request', async () => {
  const controller = new AbortController();
  controller.abort();
  const f = fixture([]);
  await assert.rejects(
    getJson('/items', { ...f, signal: controller.signal }),
    (error) => error.name === 'AbortError',
  );
  assert.equal(f.calls(), 0);
});
test('abort during backoff: no next request', async () => {
  const controller = new AbortController();
  const f = fixture([response(503)]);
  await assert.rejects(
    getJson('/items', {
      ...f,
      signal: controller.signal,
      wait: async () => controller.abort(),
    }),
    (error) => error.name === 'AbortError',
  );
  assert.equal(f.calls(), 1);
});
test('concurrent calls own separate retry budgets', async () => {
  const a = fixture([response(503), response(200, '{"name":"A"}')]);
  const b = fixture([response(200, '{"name":"B"}')]);
  const result = await Promise.all([getJson('/a', a), getJson('/b', b)]);
  assert.deepEqual(result, [{ name: 'A' }, { name: 'B' }]);
  assert.deepEqual([a.calls(), b.calls()], [2, 1]);
  assert.deepEqual([a.delays, b.delays], [[100], []]);
});
for (const staleOutcome of ['resolve', 'reject']) {
  test(`older request ${staleOutcome} cannot replace newer success`, async () => {
    const older = deferred(),
      newer = deferred();
    const loader = createLatestLoader((q) =>
      q === 'old' ? older.promise : newer.promise,
    );
    assert.equal(loader.read().status, 'idle');
    const a = loader.run('old');
    const b = loader.run('new');
    assert.equal(loader.read().status, 'loading');
    newer.resolve('new data');
    await b;
    older[staleOutcome](
      staleOutcome === 'resolve' ? 'old data' : new Error('old error'),
    );
    await a;
    assert.deepEqual(loader.read(), {
      status: 'success',
      data: 'new data',
      error: null,
    });
  });
}
test('latest failure clears prior successful data and is observable', async () => {
  const failure = new Error('unavailable');
  const loader = createLatestLoader(async (q) => {
    if (q === 'fail') throw failure;
    return 'previous';
  });
  await loader.run('ok');
  await loader.run('fail');
  assert.deepEqual(loader.read(), { status: 'error', data: null, error: failure });
});

for (const status of [502, 504]) {
  test(`${status}: retry once then succeed`, async () => {
    const f = fixture([response(status), response(200)]);
    await getJson('/items', f);
    assert.equal(f.calls(), 2);
    assert.deepEqual(f.delays, [100]);
  });
}
test('network exhaustion preserves last error and stops waiting', async () => {
  const failure = new TypeError('offline');
  const f = fixture([failure, failure, failure]);
  await assert.rejects(getJson('/items', f), (error) => error === failure);
  assert.equal(f.calls(), 3);
  assert.deepEqual(f.delays, [100, 200]);
});
test('abort after parsing starts never publishes parsed data', async () => {
  const controller = new AbortController();
  const body = deferred();
  const parsing = deferred();
  const pending = getJson('/items', {
    signal: controller.signal,
    fetchImpl: async () => ({
      ok: true,
      json: () => {
        parsing.resolve();
        return body.promise;
      },
    }),
  });
  const rejected = assert.rejects(
    pending,
    (error) => error === controller.signal.reason,
  );
  await parsing.promise;
  controller.abort(new Error('search replaced'));
  body.resolve({ stale: true });
  await rejected;
});
for (const outcome of ['resolve', 'abort']) {
  test(`abortable wait ${outcome}: releases timer and listener`, async () => {
    const controller = new AbortController();
    let callback;
    const cancelled = [];
    let removed = 0;
    const original = controller.signal.removeEventListener.bind(controller.signal);
    controller.signal.removeEventListener = (...args) => {
      removed++;
      original(...args);
    };
    const pending = abortableWait(100, controller.signal, {
      schedule: (fn, ms) => {
        assert.equal(ms, 100);
        callback = fn;
        return 7;
      },
      cancel: (id) => cancelled.push(id),
    });
    if (outcome === 'abort') {
      const rejected = assert.rejects(
        pending,
        (error) => error === controller.signal.reason,
      );
      controller.abort(new Error('cancelled'));
      await rejected;
    } else {
      callback();
      await pending;
      controller.abort();
    }
    assert.deepEqual(cancelled, [7]);
    assert.equal(removed, 1);
  });
}
test('already aborted wait does not schedule a timer', async () => {
  const controller = new AbortController();
  controller.abort();
  await assert.rejects(
    abortableWait(100, controller.signal, {
      schedule: () => assert.fail('must not schedule'),
    }),
    (error) => error.name === 'AbortError',
  );
});
test('older success cannot replace latest failure', async () => {
  const older = deferred(),
    newer = deferred();
  const failure = new Error('new search failed');
  const loader = createLatestLoader((q) =>
    q === 'old' ? older.promise : newer.promise,
  );
  const a = loader.run('old'),
    b = loader.run('new');
  newer.reject(failure);
  await b;
  older.resolve('stale');
  await a;
  assert.deepEqual(loader.read(), { status: 'error', data: null, error: failure });
});

testing-advanced.mjs 전체 코드 보기

응답 JSON 파싱을 fetch의 catch 밖에 두어 잘못된 응답 본문을 네트워크 장애로 오인하지 않습니다. wait는 대기 간격을 나타내는 경계입니다. 기본 abortableWait는 취소 신호가 오면 예약 타이머를 정리하고 signal.reason으로 거절합니다. 정상 완료에서도 타이머와 리스너를 정리합니다. signal.throwIfAborted()도 지정된 reason을 던지므로 abort(new Error(…))의 이름이 반드시 AbortError인 것은 아닙니다. JSON 파싱을 기다린 뒤에도 취소를 다시 검사해 늦게 완료된 본문을 성공값으로 내보내지 않습니다. 주입한 임의의 json 함수가 영원히 끝나지 않으면 이 검사도 도달하지 못하므로 모든 어댑터에 즉시 취소를 보장하는 구현은 아닙니다. 요청 시간 제한, jitter, 스트리밍 응답 정리는 별도 확장 과제입니다. 이 코드는 테스트 설계를 위한 작은 대상이며 완성된 운영용 HTTP 클라이언트가 아닙니다.

createLatestLoader는 호출 순서를 revision으로 기록합니다. 새로운 요청을 시작하면 loading으로 바뀌고 기존 데이터는 비웁니다. 완료 시 현재 revision과 같을 때만 success 또는 error를 기록합니다. 오래된 요청을 취소하는 것과 오래된 결과를 적용하지 않는 것은 별개입니다. 실제로 취소할 수 없는 전송 수단도 있으므로 결과 적용 경계를 따로 검증합니다. 오류를 상태로 전달하는 계약이므로 run의 정상 종료만 검사해서는 실패를 찾을 수 없습니다.

3. 시간 대신 사건의 순서를 제어하기

fixture는 테스트마다 새 응답 목록·호출 횟수·대기 기록을 만듭니다. 준비한 응답보다 많이 요청하면 즉시 실패하므로 과도한 재시도가 숨지 않습니다. Response는 Node의 실제 표준 API를 사용하지만 외부 통신은 하지 않습니다. assert.rejects는 반드시 await하고, 오류 종류뿐 아니라 요청 횟수도 함께 검사합니다. 기본 테스트 실행법은 Node.js 24 테스트 러너 문서에서 확인할 수 있습니다.

testing-advanced.test.mjs

testing-advanced.test.mjs 전체 코드 보기

테스트 파일의 TEST_SUBJECT_URL은 뒤의 변형 검사에서 메모리상의 대상 모듈을 선택하기 위한 테스트 전용 설정입니다. 일반 실행에서는 같은 폴더의 testing-advanced.mjs를 가져옵니다. fixture의 /items는 외부 서버 주소가 아니라 가짜 전송 함수에 전달하는 식별자입니다.

경쟁 상태 테스트를 순서대로 읽어 보세요. old와 new 요청을 모두 시작한 뒤 new를 먼저 resolve하고 결과를 확인할 준비를 합니다. 이후 old를 성공 또는 실패로 완료하고 두 Promise를 모두 await합니다. 마지막 상태가 new data인지를 검사하므로 컴퓨터 속도나 임의 sleep에 기대지 않습니다. 테스트가 끝날 때 처리하지 않은 이전 요청도 남기지 않습니다.

대기 함수에 [100, 200]이 전달되는지 확인하는 것은 재시도 계획의 계약 검증입니다. 실제로 100ms가 지났는지 검증하는 것은 아니므로 OS 타이머 정밀도가 보장됐다고 해석하지 않습니다. 동시 요청 테스트 역시 두 호출이 서로 다른 응답·예산·대기 기록을 사용하는지를 검증합니다. globalThis.fetch를 전역 교체하지 않아 다른 테스트의 통신 설정이 오염되지 않습니다.

새 테스트는 JSON 파싱 시작 신호를 별도 deferred로 기다린 뒤 취소하므로 임의 sleep을 쓰지 않습니다. 타이머 테스트도 콜백을 직접 실행하거나 AbortController를 취소해 정상 종료와 취소 경로의 정리를 각각 검사합니다. 기존 17개에 502·504, 네트워크 소진, 본문 완료 뒤 취소, 타이머 정리 두 경로, 사전 취소 대기, 최신 실패 보호를 추가해 총 25개입니다.

4. 통과하는 테스트가 버그도 잡는지 확인하기

다음 검사는 원본 파일을 수정하지 않고 메모리에만 버그가 들어간 모듈을 만듭니다. 실패를 null로 삼키기, 오래된 응답 방어 제거, 재시도 대기값 변경, 파싱 후 취소 검사 제거, 타이머 정리 제거의 다섯 변형마다 자식 테스트 실행이 assertion 실패로 끝나는지 확인합니다. 패키지 설치나 실제 서버 요청은 없습니다.

testing-advanced.mutation.mjs

testing-advanced.mutation.mjs 전체 코드 보기

단순히 종료 코드가 0이 아닌지만 보면 구문 오류도 버그 검출로 오인할 수 있습니다. 그래서 assertion 오류도 함께 확인합니다. 다만 이것은 선정한 5개 변형에 대한 검증이며 모든 가능한 버그를 잡는다는 증명이나 전체 mutation score는 아닙니다.

5. 실행 명령과 실제 결과

node --version
node --test testing-advanced.test.mjs
node testing-advanced.mutation.mjs

2026-09-12 작성 환경에서 Node v24.19.0으로 테스트 25개가 모두 통과했고 fail·cancelled·skipped는 0이었습니다. 변형 검사에서는 DETECTED 로그가 5개 나왔습니다. 새로운 npm 패키지 설치, Docker, 실제 서버 접속, 브라우저 실행은 하지 않았습니다. 같은 예제를 복사한 독자의 기대 결과도 25개 통과와 5개 변형 검출입니다.

tests 25
pass 25
fail 0
DETECTED HTTP failure swallowed
DETECTED no stale response guard
DETECTED wrong backoff delay
DETECTED missing post-parse abort guard
DETECTED timer cleanup removed

단위 검증의 범위는 재시도 정책과 상태 적용 규칙입니다. 실제 브라우저 fetch, DNS, TLS, 서버의 오류 응답 형식, 접근성 이름, React 렌더링까지 확인한 결과는 아닙니다. 제품에 연결한 뒤에는 실제 어댑터와 UI를 대상으로 별도 통합/E2E 검사를 추가합니다.

6. 확장 실습과 완료 기준

  1. 제공된 502·504 표 기반 테스트를 읽고 maxAttempts=5 상한에서도 요청 5회와 대기 [100, 200, 400, 800]을 검증하는 사례를 추가하세요.
  2. 제공된 최신 실패·이전 성공 순서 검증을 확장해 이전 요청이 나중에 실패해도 최신 오류 객체가 그대로 남는지 확인하세요.
  3. abortableWait의 schedule/cancel 주입 테스트를 읽고 사용자 지정 취소 reason이 보존되는 이유를 설명하세요. 즉시 실행하는 schedule은 이 타이머 경계의 계약이 아니며 실제 setTimeout처럼 콜백을 나중에 호출해야 합니다.
  4. 이 상태 모델을 검색 폼에 연결하고 loading·success·error를 React Testing Library로 검사하세요. 실제 API를 흉내 내는 경계와 DOM 경계를 구별해 기록합니다.

완료 기준은 25개 기본 검증과 5개 변형 검출을 재현하고, 구현의 오류를 수정하기 전에 실패를 고정된 응답 순서로 재현할 수 있는 것입니다. flakes가 생기면 실행 순서, 공유 저장소, 남아 있는 비동기 작업, 통신 응답을 먼저 기록하세요. assertion을 약하게 바꾸거나 catch에서 무조건 성공값을 반환하지 않습니다.

7. Playwright로 이어갈 때 지킬 경계

Playwright 재실행 문서의 retries는 실패한 테스트 전체를 다시 실행하는 설정입니다. 이 글의 getJson 재시도는 앱의 GET 요청 정책입니다. 둘을 같은 것으로 보지 않습니다. 재실행에서 통과한 flaky 결과를 최초 실행 통과와 구분하고 실패 당시 trace와 통신 기록을 보존해야 합니다.

Playwright 격리 문서처럼 테스트마다 브라우저 컨텍스트를 분리하면 쿠키와 localStorage 공유를 줄일 수 있습니다. 그러나 서버의 같은 사용자 계정이나 같은 행까지 자동으로 분리되지는 않습니다. E2E에서는 테스트별 데이터 이름·계정·정리 범위를 별도로 설계합니다. 기본 접속과 클릭은 첫 E2E 실습, 선택자 오류는 locator 체크리스트에 맡기고 여기서 만든 실패 시나리오를 네트워크 경계에 옮겨 검증하세요.

실행 가능한 예제 다운로드

본문과 같은 전체 실습 ZIP 다운로드. 압축을 풀고 README.md의 명령을 실행하세요. 의존성 폴더와 빌드 산출물은 포함하지 않습니다.

공식 출처

공식 문서 확인일: 2026-09-12.

이 글이 도움이 되었나요?

조회 중

테스트 학습 순서

필수 7개 · 전체 9개

읽음 기록 관리

전체 과정 목차 (9개)
  1. 필수 학습 · Vite 프로젝트에서 Vitest로 테스트 환경 시작하기
  2. 필수 학습 · React Testing Library로 클릭·입력 테스트 작성하기
  3. 선택 참고 · Vitest document is not defined 오류 해결: jsdom 설정 기준
  4. 필수 선수 · Jest console.error 테스트: try catch 에러 검증
  5. 필수 학습 · React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증
  6. 필수 학습 · TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증
  7. 필수 학습 · 비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기 현재 글
  8. 필수 학습 · Playwright 첫 E2E 테스트: 로컬 앱 실행부터 결과 확인까지
  9. 선택 참고 · Playwright 테스트 실패 해결: locator가 요소를 찾지 못할 때 체크리스트

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기