본문 바로가기

테스트 사례와 오라클

전제, 입력, 행동, 기대 결과, 기대 상태로 이루어진 테스트 사례를 쓰고, 기대 결과를 실행 결과가 아니라 요구에서 끌어내는 오라클을 정합니다. node:test로 첫 자동 테스트를 만들고, 일부러 넣은 결함을 테스트가 잡아내는지 확인합니다.

개발자가 함수를 실행해 나온 값을 그대로 테스트의 정답으로 옮겨 적으면, 함수에 있던 잘못까지 정답이 되어 버립니다. 그래서 테스트를 만들 때 먼저 정해야 할 것은 실행하는 방법보다 무엇을 옳은 결과로 볼지입니다. 이 글에서는 테스트 사례를 이루는 요소와 결과의 옳고 그름을 판단하는 기준인 오라클을 정하고, Node.js에 들어 있는 테스트 도구로 첫 자동 테스트를 만들어 봅니다.

테스트 사례와 테스트 오라클

테스트 사례(test case)는 전제가 되는 상태, 입력과 실행할 행동, 기대하는 결과를 하나로 묶은 확인 단위입니다. 그리고 실제 결과가 옳은지 판단하는 근거를 테스트 오라클(test oracle)이라고 합니다. 이 교재에서는 기능 요구와 수락 조건에서 정한 요구 표와 불변 조건이 오라클입니다.

중복 신청 사례 하나를 항목별로 나누어 적으면 다음과 같습니다.

항목중복 신청 사례
전제열린 모임, 정원 1, 참가자 min
입력과 행동min이 다시 신청한다
기대하는 결과 코드ALREADY_REGISTERED
기대하는 상태참가자는 min 하나, 넘긴 상태 그대로
오라클열린 모임에서는 중복 검사 R3이 정원 검사 R4보다 먼저다

정원이 1이고 이미 min이 신청했으므로 이 모임은 정원이 찬 상태입니다. 그래서 이 사례 하나로 중복 검사가 정원 검사보다 먼저라는 순서까지 확인할 수 있습니다. 또 결과 코드만 확인하면 거절하면서 명단을 바꾸는 결함을 놓치므로, 성공했을 때는 새 명단을, 거절했을 때는 상태가 그대로인지를 결과 코드와 함께 확인합니다. ok가 true인데 code는 거절 코드인 것처럼 결과의 값끼리 서로 어긋나는지도 검토할 수 있습니다.

처음 만드는 자동 테스트

Node.js에는 따로 설치하지 않아도 쓸 수 있는 테스트 도구 node:test가 들어 있습니다. 다음 코드를 reading-lab/core.test.mjs로 저장합니다. 이 파일은 examples 폴더가 아니라 core.mjs와 같은 폴더에 두므로, import 경로가 ../core.mjs가 아니라 ./core.mjs입니다.

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

test("자리가 있으면 첫 신청을 등록하고 이전 상태는 그대로 둔다", () => {
  const before = createSession({ id: "book-1", title: "첫 책", capacity: 1 });
  const result = register(before, "min");
  assert.equal(result.code, "REGISTERED");
  assert.deepEqual(result.state.participants, ["min"]);
  assert.deepEqual(before.participants, []);
});

test("정원이 찬 열린 모임에 같은 아이디로 다시 신청하면 중복으로 거절한다", () => {
  const initial = createSession({ id: "book-1", title: "첫 책", capacity: 1 });
  const before = register(initial, "min").state;
  const result = register(before, "min");
  assert.equal(result.code, "ALREADY_REGISTERED");
  assert.equal(result.state, before);
});

reading-lab 폴더에서 다음 명령으로 테스트를 실행합니다. --test를 붙이면 Node.js가 내장 테스트 실행기로 동작해, 뒤에 적은 파일을 실행하고 그 안에서 test로 등록한 테스트마다 통과했는지를 모아 보여 줍니다. 파일 이름은 core.test.mjs를 저장한 위치를 기준으로 적으므로, 다른 폴더에서 실행하면 파일을 찾지 못합니다.

node --test core.test.mjs

test는 테스트의 이름과 실행할 함수를 받습니다. 함수 안의 단언이 모두 통과하면 그 테스트는 성공하고, 하나라도 오류를 던지면 실패합니다. 두 테스트가 모두 통과하면 각 이름 앞에 ✔ 표시가 붙고, 마지막 요약에 pass 2와 fail 0이 나옵니다. 요약에는 걸린 시간도 함께 나오는데, 이 값은 컴퓨터와 실행할 때마다 달라집니다.

assert.equal은 문자열이나 숫자는 값이 같은지, 객체는 같은 객체인지를 비교하므로, 둘째 테스트의 assert.equal(result.state, before)는 거절할 때 넘긴 상태를 그대로 돌려주었는지 확인합니다. 반면 assert.deepEqual은 배열이나 객체 안의 값을 하나씩 비교하므로, 새로 만든 배열이 ["min"]과 같은 내용인지 확인할 때 씁니다. 각 테스트는 다른 테스트가 바꾼 상태를 쓰지 않고 자기 전제를 새로 만들어서, 테스트의 순서가 바뀌어도 결과가 달라지지 않습니다. 테스트의 이름에는 확인할 행동과 조건을 함께 적어, 실패했을 때 이름만 보고도 무엇이 깨졌는지 알 수 있게 합니다.

node:test의 다른 기능은 Node.js 공식 문서에서 찾아볼 수 있습니다. 이 두 사례만으로는 취소, 마감, 복원을 확인하지 못하므로, 요구를 하나 더할 때마다 그 요구에 해당하는 사례가 테스트 파일에 있는지 확인하는 습관을 들입니다.

결함을 일부러 넣어 본다

테스트가 정말 결함을 잡아내는지 알아보려면, 결함이 있는 코드에서 실행해 보면 됩니다. core.mjs와 core.test.mjs를 함께 다른 폴더에 복사한 뒤, 복사본의 register에서 정원 검사 줄을 중복 검사 줄 위로 옮기고 그 폴더에서 node --test core.test.mjs로 테스트를 실행해 봅니다. 테스트 파일 안의 ./core.mjs는 테스트 파일이 있는 위치를 기준으로 찾으므로, 테스트 파일도 함께 복사해야 복사본이 시험됩니다. 그러면 첫째 테스트는 통과하지만 둘째 테스트는 ALREADY_REGISTERED를 기대했는데 FULL을 받아 실패합니다.

이처럼 작은 결함을 일부러 넣고 테스트가 그것을 찾아내는지 보는 방법은 테스트가 얼마나 잘 잡아내는지를 평가하는 데 도움이 됩니다. 결함을 넣은 테스트가 여전히 통과한다면, 그 결함이 일으키는 상황을 시험하는 사례가 없다는 뜻입니다. 실험이 끝나면 복사본은 지우고 원래 파일로 다시 실행해 모두 통과하는지 확인합니다.

테스트가 실패했다고 언제나 코드가 틀린 것은 아닙니다. 전제를 잘못 만들었거나, 기대 결과가 예전 정책을 따르고 있을 수도 있습니다. 그래서 실패하면 무엇이 달랐는지, 요구는 무엇을 말하는지, 같은 상황을 다시 만들 수 있는지를 차례로 확인합니다. 실패할 때마다 기대 결과를 지금의 실행 결과에 맞춰 고치면, 테스트는 더 이상 아무것도 판단하지 못하게 됩니다.

더 깊이: 결함 넣기를 자동으로 되풀이하는 변이 테스트

결함을 일부러 넣어 테스트를 평가하는 일을 도구로 자동화한 것을 변이 테스트(mutation testing)라고 합니다. 도구는 a < b를 a <= b로, a && b를 a || b로 바꾸거나 if의 조건을 늘 참이나 늘 거짓으로 바꾸는 식으로 코드를 조금씩 고친 변이체(mutant)를 여러 개 만들고, 변이체마다 테스트를 다시 돌립니다. JavaScript에서 쓰는 Stryker의 문서에 따르면, 테스트가 하나라도 실패하면 그 변이체는 잡힌(killed) 것이고, 모든 테스트가 통과하면 살아남은(survived) 것이며, 그 코드를 실행하는 테스트가 아예 없으면 실행되지 않은(no coverage) 변이체로 따로 셉니다.

변이 점수(mutation score)는 잡히거나 시간 초과로 끝난 변이체의 수를, 거기에 살아남거나 실행되지 않은 변이체를 더한 수로 나눈 비율입니다. 살아남은 변이체는 테스트가 그 바뀐 동작을 구별하지 못한다는 단서이므로, 이 글에서 정원 검사의 순서를 바꾸어 본 것처럼 빠진 사례를 찾는 출발점이 됩니다. 다만 고친 코드가 원래와 똑같이 동작하는 동등 변이체(equivalent mutant)도 생길 수 있어서, 살아남았다고 모두 테스트의 빈틈은 아니며 하나씩 읽고 판단해야 합니다.

정리

  • 테스트 사례는 전제, 입력과 행동, 기대하는 결과를 묶은 확인 단위이고, 테스트 오라클은 결과가 옳은지 판단하는 근거입니다.
  • 기대 결과는 실행 결과를 옮겨 적지 않고 요구 표와 불변 조건에서 끌어냅니다.
  • 결과 코드만이 아니라, 성공했을 때의 새 상태와 거절했을 때 그대로 남은 상태를 함께 확인합니다.
  • node:test의 각 테스트는 자기 전제를 새로 만들고, assert.equal과 assert.deepEqual의 비교 방식을 구별해 씁니다.
  • 결함을 일부러 넣어 보면 테스트가 무엇을 잡아내지 못하는지 알 수 있고, 실패한 테스트의 기대 결과를 실행 결과에 맞춰 고치지 않습니다.

연습 문제

1. 상태를 확인하지 않은 테스트

register에 결함이 있어, 정원이 찬 모임에 jun이 신청하면 FULL을 돌려주면서도 명단에 jun을 더합니다. code만 확인하는 테스트는 이 결함을 놓칩니다. 이 결함을 잡아낼 테스트를 작성하세요.

해설 보기

정원이 1이고 min이 신청한 열린 모임을 전제로 만들고, jun의 신청 결과를 확인합니다.

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

test("정원이 찬 모임의 새 신청은 FULL로 거절하고 명단을 바꾸지 않는다", () => {
  const initial = createSession({ id: "book-2", title: "둘째 책", capacity: 1 });
  const before = register(initial, "min").state;
  const result = register(before, "jun");
  assert.equal(result.code, "FULL");
  assert.deepEqual(result.state.participants, ["min"]);
  assert.equal(result.state, before);
});

결함이 있는 코드에서는 둘째 단언이 [ 'min', 'jun' ]과 [ 'min' ]이 다르다며 실패합니다. 마지막 단언은 거절할 때 넘긴 상태를 그대로 돌려준다는 이 교재의 약속까지 확인합니다. 거절하면 내용이 같은 새 객체를 돌려주기로 한 구현이라면, 요구에 없는 이 조건은 빼고 명단의 내용만 확인합니다.

2. 실행 결과를 정답으로 옮겨 적으면

새로 합류한 개발자가 register를 여러 입력으로 실행해 나온 결과 코드를 그대로 기대 결과로 적어 테스트 50개를 만들었습니다. 모두 통과했다면 이 테스트로 무엇을 알 수 있고 무엇을 알 수 없나요?

해설 보기

이 테스트는 앞으로 코드를 고쳤을 때 결과가 지금과 달라지는지를 알려 줄 수는 있습니다. 하지만 지금의 결과가 요구에 맞는지는 알려 주지 못합니다. 예를 들어 지금 코드가 중복 검사보다 정원 검사를 먼저 한다면 FULL이 기대 결과로 적혀, 요구 R3을 어긴 동작이 정답으로 굳어집니다. 기대 결과마다 요구 표의 어느 행에서 왔는지 적어 두면 이런 문제를 막을 수 있습니다.

3. 테스트가 서로 상태를 나누어 쓰면

두 테스트가 파일 맨 위에서 만든 let session 하나를 함께 쓰고, 첫 테스트가 session = register(session, "min").state로 그 값을 바꾼다고 합시다. 둘째 테스트가 min이 이미 신청했다고 가정하고 작성되어 있다면 어떤 문제가 생기나요?

해설 보기

둘째 테스트의 결과가 첫 테스트가 먼저 실행되었는지에 따라 달라집니다. 둘째 테스트만 따로 실행하거나 실행 순서가 바뀌면 min이 없는 상태에서 시작해 실패하므로, 코드는 그대로인데 테스트가 성공하기도 하고 실패하기도 합니다. 이런 테스트는 실패해도 원인을 찾기 어렵습니다. 본문의 예제처럼 각 테스트가 createSession과 register로 자기 전제를 새로 만들면 이 문제가 생기지 않습니다.

몇 가지 사례를 통과했다고 결함이 없다고 결론 내릴 수는 없습니다. 다음 글 동치 분할과 경계값에서는 입력의 범위를 나누어 아직 시험하지 않은 영역을 찾는 방법을 다룹니다.