본문 바로가기

통합·인수·회귀 테스트

단위, 통합, 시스템, 인수 테스트가 각각 어디까지를 확인하는지 나누고, 저장과 복원을 잇는 통합 사례를 실행합니다. 스텁과 드라이버 같은 테스트 대역으로 저장 실패를 흉내 내 보고, 변경 뒤에 다시 확인할 회귀 사례를 고릅니다.

신청 함수가 완벽하게 동작해도, 저장한 파일을 다시 읽을 때 참가자 목록이 빠진다면 다음 실행에서 정원을 잘못 계산합니다. 부분 하나하나의 규칙과 부분 사이의 연결은 서로 다른 곳에서 틀릴 수 있으므로 따로 확인해야 합니다. 이 글에서는 테스트가 확인하는 범위에 따라 수준을 나누고, 저장과 복원이 이어지는 곳을 시험한 뒤, 코드를 바꾼 뒤에 무엇을 다시 확인할지 정합니다.

테스트가 확인하는 범위

단위 테스트(unit test)는 함수나 모듈처럼 작은 부분을 떼어 그 규칙을 확인합니다. 통합 테스트(integration test)는 여러 부분을 이었을 때 그 사이에서 주고받는 값과 약속이 맞는지 확인하고, 시스템 테스트(system test)는 완성된 시스템 전체가 기능과 품질 요구를 만족하는지 확인합니다. 인수 테스트(acceptance test)는 사용자나 발주자가 업무의 목적과 수락 조건에 비추어 시스템을 받아들일지 판단하는 테스트입니다.

이 네 수준은 어떤 도구를 쓰느냐가 아니라, 어디까지를 이어서 확인하느냐와 누가 무엇을 판단하느냐로 나뉩니다. 같은 node:test로 register 하나만 시험하면 단위 테스트이고, 저장한 뒤 복원해 다시 신청하는 과정을 시험하면 통합 테스트가 됩니다.

저장과 복원을 잇는 통합 테스트

값을 파일에 저장하거나 네트워크로 보낼 수 있는 표현으로 바꾸는 일을 직렬화(serialization)라 하고, 그 표현을 다시 프로그램이 쓸 수 있는 값으로 되돌리는 일을 역직렬화(deserialization)라고 합니다. 다음 코드는 실제 파일 대신 JSON 문자열을 거쳐 상태를 저장하고 되살린 뒤, 되살린 상태로 신청 규칙이 그대로 동작하는지 확인합니다. reading-lab/examples/ch5-4.mjs로 저장해 실행합니다.

import assert from "node:assert/strict";
import { createSession, register, restoreSession } from "../core.mjs";

const initial = createSession({ id: "book-1", title: "첫 책", capacity: 2 });
const saved = register(initial, "min").state;
const encoded = JSON.stringify(saved);
const restored = restoreSession(JSON.parse(encoded));
assert.deepEqual(restored.participants, ["min"]);
assert.equal(register(restored, "min").code, "ALREADY_REGISTERED");
assert.equal(register(restored, "hana").code, "REGISTERED");
console.log(encoded);
console.log("roundtrip PASS");

const tampered = JSON.parse(encoded);
tampered.participants.push("min");
assert.throws(() => restoreSession(tampered), TypeError);
console.log("tampered data rejected");
{"id":"book-1","title":"첫 책","capacity":2,"status":"OPEN","participants":["min"]}
roundtrip PASS
tampered data rejected

JSON.stringify가 만든 첫 줄의 문자열이 저장될 표현이고, JSON.parse와 restoreSession이 그것을 다시 상태로 만듭니다. 되살린 상태에서도 min의 재신청은 중복으로 거절되고 hana는 등록되므로, 저장을 거쳐도 명단이 빠지지 않았다는 것을 확인했습니다. 이렇게 저장했다가 되살린 값이 원래와 같은지 확인하는 사례를 왕복(round-trip) 테스트라고 합니다.

JSON을 읽을 수 있다고 해서 그 안의 상태가 유효한 것은 아닙니다. 마지막 부분은 저장된 자료에 같은 아이디를 하나 더 넣어 망가뜨린 뒤, restoreSession이 TypeError를 던지는지 assert.throws로 확인합니다. 다만 이 예제는 실제 디스크에 쓰다 실패하는 경우나 두 프로그램이 동시에 같은 파일을 쓰는 경우를 확인하지 않으며, 이런 문제는 저장 방식 변경과 의존성과 동시 저장과 복구에서 다시 다룹니다.

수준과 기법은 다른 분류

테스트를 나누는 기준은 하나가 아닙니다. 확인하는 범위로 나눈 수준, 사례를 고르는 근거로 나눈 기법, 확인하려는 위험으로 나눈 목적은 서로 다른 축이므로, 한 테스트가 여러 분류에 동시에 속할 수 있습니다.

질문분류의 축모임 신청의 예
어디까지 이어서 확인하는가단위, 통합, 시스템register 하나, 저장 뒤 복원, 화면부터 저장까지
무엇을 근거로 사례를 고르는가명세 기반(블랙박스), 구조 기반(화이트박스)정원의 끝값, 거절 분기
어떤 변경의 위험을 확인하는가회귀저장 방식을 바꾼 뒤에도 중복 거절이 유지되는지
누가 어떤 목적을 받아들이는가인수진행자가 마감과 취소의 업무 흐름을 확인

회귀 테스트(regression test)는 코드를 바꾼 뒤 이미 동작하던 기능이 깨지지 않았는지 다시 확인하는 테스트입니다. 바꾼 부분의 새 테스트와 그 변경의 영향을 받을 수 있는 기존 테스트를 함께 고릅니다. 테스트가 많고 오래 걸리는 시스템에서는 영향을 받는 테스트를 골라 실행하는 전략이 필요하지만, 이 교재의 작은 코어처럼 전체 테스트가 금방 끝난다면 바꿀 때마다 전부 다시 실행하는 것이 가장 간단합니다.

통합의 순서와 테스트 대역

부분을 잇는 순서에도 선택지가 있습니다. 위에서 아래로 이어 가는 하향식 통합에서는 아직 만들지 않은 아래쪽 부분을 대신할 스텁(stub)이 필요하고, 아래에서 위로 이어 가는 상향식 통합에서는 아직 없는 호출자를 대신해 아래쪽 부분을 불러 줄 드라이버(driver)가 필요합니다. 모든 부분을 한꺼번에 이어 붙이는 빅뱅(big bang) 방식은 준비가 간단하지만, 실패했을 때 어느 연결에서 틀렸는지 찾기 어렵습니다.

스텁과 드라이버처럼 테스트에서 진짜 부분을 대신하는 것을 묶어 테스트 대역(test double)이라고 합니다. 테스트 대역을 쓰면 실제로는 만들기 어려운 상황, 예를 들어 저장이 실패하는 상황도 쉽게 만들 수 있습니다. 다음 코드는 신청한 뒤 저장까지 하는 함수를, 늘 성공하는 저장 스텁과 늘 거부되는 저장 스텁으로 시험합니다. reading-lab/examples/ch5-4-stub.mjs로 저장해 실행합니다.

import assert from "node:assert/strict";
import { createSession, register } from "../core.mjs";

function registerAndSave(session, memberId, save) {
  const result = register(session, memberId);
  if (!result.ok) return { message: result.code, state: session };
  const saved = save(result.state);
  if (!saved.ok) return { message: "SAVE_FAILED", state: session };
  return { message: "REGISTERED", state: result.state };
}

const session = createSession({ id: "book-2", title: "둘째 책", capacity: 2 });
const alwaysSaves = () => ({ ok: true });
const deniedDisk = () => ({ ok: false, reason: "EACCES" });

const saved = registerAndSave(session, "min", alwaysSaves);
const failed = registerAndSave(session, "min", deniedDisk);
assert.equal(failed.state, session);
console.log(saved.message, saved.state.participants.join(","));
console.log(failed.message, failed.state.participants.length);
REGISTERED min
SAVE_FAILED 0

registerAndSave는 저장 함수를 인수로 받으므로, 테스트에서는 진짜 저장 대신 스텁을 넘길 수 있습니다. 저장이 성공하면 REGISTERED와 새 명단을, 실패하면 SAVE_FAILED와 함께 처음 상태를 돌려주어, 저장되지 않은 신청을 성공으로 안내하지 않는다는 것을 확인했습니다. 그러나 이것은 저장이 실패했을 때 이 함수가 어떻게 처리하는지를 확인한 것이지, 실제 운영체제의 권한 오류나 디스크의 동작까지 확인한 것은 아닙니다. 테스트 대역이 통과했다고 진짜 저장과의 연결까지 확인했다고 여기면 안 되며, 실제 저장을 잇는 통합 테스트는 따로 필요합니다.

더 깊이: 스텁, 스파이, 목은 어떻게 다른가

테스트 대역이라는 말은 Gerard Meszaros가 xUnit 테스트의 패턴을 정리하며 쓴 용어로, 영화에서 배우 대신 위험한 장면을 찍는 대역 배우(stunt double)에서 따왔습니다. Martin Fowler의 글 Mocks Aren't Stubs는 이 용어를 따라 대역을 다섯 가지로 나눕니다. 인수 목록을 채우려고 넘기기만 하고 쓰지 않는 더미(dummy), 메모리 안의 데이터베이스처럼 실제로 동작하지만 운영에는 맞지 않게 지름길을 택한 페이크(fake), 정해 둔 답만 돌려주는 스텁(stub), 스텁이면서 어떻게 불렸는지를 기록하는 스파이(spy), 받아야 할 호출을 미리 기대로 정해 두는 목(mock)입니다.

Fowler는 이 가운데 목만이 행동 검증(behavior verification)을 고집한다고 정리합니다. 다른 대역을 쓰는 테스트는 대개 실행이 끝난 뒤의 결과와 상태를 확인하는 상태 검증(state verification)을 하고, 목을 쓰는 테스트는 대상이 협력 객체를 올바른 순서와 인수로 불렀는지를 확인합니다. 본문의 저장 스텁은 정해 둔 답만 돌려주고 그 뒤의 결과 코드와 명단을 확인하므로 상태 검증에 해당합니다. Node.js 24의 node:test에는 mock.fn()이 있어서, const save = mock.fn(() => ({ ok: true }))처럼 만든 저장 함수가 몇 번, 어떤 인수로 불렸는지를 save.mock.callCount()와 save.mock.calls로 읽을 수 있는 스파이를 따로 만들지 않고 얻을 수 있습니다.

인수 시나리오와 정적 검토

인수 테스트에서는 진행자가 실제 업무 순서대로 시스템을 써 봅니다. 예를 들어 "정원 2인 모임에 min과 hana가 신청하고, min이 취소한 뒤 jun이 신청하고, 모집을 마감한 뒤 새 신청이 거절되는지 보고, 마지막으로 hana가 취소한다"는 시나리오를 따라가며 명단, 안내 문구, 마감 상태를 확인합니다. 자동 테스트는 각 단계의 상태와 결과 코드를 확인해 주고, 진행자의 검토는 안내 문구가 업무에 맞는지를 판단합니다. 화면의 사용성, 권한, 실제 복구 절차는 그 기능이 갖추어진 환경에서 따로 확인합니다.

테스트는 프로그램을 실행해 결과를 보는 동적 테스트(dynamic testing)입니다. 이와 달리 실행하지 않고 요구 명세나 설계, 코드를 읽어 문제를 찾는 활동을 정적 검토(static review)라고 하며, 코딩 규칙과 코드 검토의 코드 검토도 여기에 속합니다. 개발이 끝난 뒤에 테스트를 몰아서 하기보다, 요구와 설계를 검토하는 일과 실행해 확인하는 일을 개발 내내 이어서 하는 편이 결함을 일찍 찾습니다.

정리

  • 단위, 통합, 시스템, 인수 테스트는 도구가 아니라 확인하는 범위와 판단하는 사람으로 나뉩니다.
  • 통합 테스트는 부분 사이에서 주고받는 값을 확인하며, 저장한 뒤 되살린 상태로 규칙이 그대로 동작하는지 보는 왕복 테스트가 그 예입니다.
  • 테스트의 수준, 사례를 고르는 기법, 회귀나 인수 같은 목적은 서로 다른 축의 분류입니다.
  • 스텁과 드라이버 같은 테스트 대역으로 만들기 어려운 실패 상황을 시험할 수 있지만, 진짜 부분과의 연결은 따로 확인해야 합니다.
  • 회귀 테스트는 변경 뒤에 이미 되던 동작이 유지되는지 확인하며, 정적 검토와 동적 테스트를 개발 내내 함께 씁니다.

연습 문제

1. 인수 시나리오 따라가기

본문의 인수 시나리오를 core.mjs로 실행한다고 할 때, 각 단계의 결과 코드와 마감 여부, 명단을 예측하세요. 모집을 마감한 뒤 새로 신청하는 사람은 sora라고 합니다.

해설 보기

min과 hana의 신청은 모두 REGISTERED이고 명단은 min, hana가 됩니다. min의 취소는 CANCELLED로 명단이 hana만 남고, 자리가 생겼으므로 jun의 신청은 REGISTERED가 되어 명단은 hana, jun입니다. 마감한 뒤에는 상태가 CLOSED가 되고, sora의 새 신청은 CLOSED로 거절되어 명단은 그대로입니다. 마지막으로 hana의 취소는 CANCELLED이고, 명단에는 jun만 남으며 상태는 여전히 CLOSED입니다.

2. 스텁에서는 통과한 저장

늘 성공하는 저장 스텁으로 실행한 테스트는 모두 통과했는데, 실제로 배포하자 저장 폴더에 쓰기 권한이 없어 오류가 났습니다. 어떤 테스트를 더해야 하며, 사용자에게는 무엇을 안내해야 할까요?

해설 보기

먼저 본문의 deniedDisk처럼 실패를 돌려주는 대역으로, 저장에 실패하면 신청이 성공으로 안내되지 않고 처음 상태가 유지되는지를 확인합니다. 그리고 실제 저장 폴더에 파일을 쓰고 다시 읽는 통합 테스트를 더해, 권한이나 경로 문제가 배포 전에 드러나게 합니다. 사용자에게는 신청을 저장하지 못했다는 사실과 다시 시도하는 방법을 알리고, 다시 시도할 때 같은 신청이 두 번 들어가지 않는지도 확인해야 합니다. 실패를 흉내 낸 테스트는 이 함수의 오류 처리를 확인할 뿐, 운영체제의 권한 동작까지 대신 확인해 주지는 않습니다.

3. 저장 형식을 바꾼 뒤의 회귀 사례

저장 형식을 바꾸어 참가자를 아이디 문자열 대신 { id, joinedAt } 객체로 저장하기로 했습니다. 이 변경 뒤에 다시 확인해야 할 사례를 고르세요.

해설 보기

본문의 왕복 테스트처럼 저장한 뒤 되살린 상태로 중복 거절과 새 등록이 그대로 동작하는지 먼저 확인합니다. 망가진 자료, 곧 중복 아이디나 정원을 넘은 명단을 복원할 때 여전히 거절하는지도 다시 확인해야 하며, 예전 형식으로 저장된 파일을 읽을 수 있어야 한다면 그 사례도 더합니다. 신청 규칙 자체는 바꾸지 않았더라도 그 규칙이 되살린 상태에 기대고 있으므로, 이 교재의 작은 코어에서는 전체 테스트를 다시 실행하는 것이 가장 확실합니다.

이 장에서는 기대 결과를 요구에서 끌어내는 일부터, 입력과 코드 구조로 사례를 고르는 방법, 부분을 이어 확인하는 수준까지 다루었습니다. 다음 장의 개발 프로세스 모델의 선택에서는 이런 활동을 어떤 순서와 주기로 진행할지 정하는 개발 프로세스를 살펴봅니다.