정상·경계·실패 테스트
140분 안팎
학습 목표
단위 테스트와 브라우저 흐름 테스트의 범위를 정합니다.
개념
검사 경계를 먼저 정합니다
행사 검색 함수를 고쳤는데 상세에서 목록으로 돌아오면 지역 조건이 사라졌다는 제보가 왔습니다. 검색 함수에 부산 데이터를 넣어 맞는 id를 받는 검사만으로는 이 문제를 찾지 못합니다. 계산의 정확성과 사용자가 화면에서 일을 끝낼 수 있는지는 검사 경계가 다릅니다. 먼저 실패했을 때 누구의 계약을 확인할지 정하면 테스트 수를 늘리는 것보다 빠르게 원인을 좁힐 수 있습니다.
단위 테스트는 외부 환경을 좁히고 한 책임을 검사합니다. searchEvents에는 행사 배열·검색어·지역을 넣고 정렬된 id를 비교합니다. 브라우저 테스트는 폼 입력·네트워크 응답·화면 이동을 함께 실행합니다. 검색어를 넣고 상세를 연 뒤 돌아와 폼과 URL과 카드가 같은 조건인지 확인합니다. 작은 검사는 계산 규칙을, 큰 검사는 연결 규칙을 설명합니다.
이번 모듈의 시작점은 m07 미션 solution입니다. ESM 진입점은 boot.mjs이며 results.mjs가 URL 조건을 소유합니다. labs의 starter에는 이 파일과 이전 회귀 테스트를 이미 복사했습니다. 실습 공통 환경은 Node.js 20 이상·Python 3이며 npm ci로 lockfile의 Playwright 1.51.1을 설치합니다. 최초 브라우저 설치는 npx playwright install chromium으로 진행합니다.
npm test는 문서 검사와 Node의 DOM 모델을 실행합니다. npm run test:e2e는 Playwright로 실제 브라우저를 실행하고, npm run verify는 둘을 순서대로 실행합니다. 이 작성 환경에서는 서버 바인딩과 Chrome 실행이 차단되어 브라우저 실습은 external입니다. 해당 검사가 PENDING이면 성공으로 바꾸지 않습니다. 모델 실행 결과와 외부 브라우저 결과와 수동 관찰을 각각 기록합니다.
정상 사례부터 업무 언어로 적습니다. “책·부산을 검색하면 마을 책 모임이 나온다”는 계약을 입력 q=책, region=busan, 기대 id=[2]로 바꿉니다. 이름 전체를 여러 테스트에 복사하면 문구 편집 때문에 계산 검사가 깨집니다. 계산 테스트는 식별자와 순서에 집중하고, 화면 테스트는 읽을 수 있는 이름과 이동 경로를 확인하는 방식으로 관찰 값을 선택합니다.
경계는 입력이 없는 순간이나 규칙이 갈리는 지점입니다. 빈 문자열과 공백만 있는 검색어는 전체 행사를 뜻합니다. 영문 대소문자는 같은 검색으로 취급합니다. 공백 정규화는 양 끝 제거와 연속 공백 한 칸 변환입니다. 공백 검사를 빈 결과 검사와 섞지 않습니다. 공백을 검색하면 전체 목록이며, 존재하지 않는 단어를 검색하면 정상적인 빈 배열입니다.
복합 조건은 교집합으로 검사합니다. “책”이 제목에 있어도 서울 행사는 부산 필터에서 제외합니다. 검색어가 맞는다는 이유로 지역 검사를 건너뛰는 구현은 q만 검사하는 정상 사례를 모두 통과할 수 있습니다. q와 region을 동시에 넣는 사례를 추가하면 조건이 AND로 연결됐는지 확인할 수 있습니다. 실패 시 기대 id와 실제 id의 차이로 빠진 조건을 찾습니다.
정렬에는 동률 사례가 필요합니다. 날짜가 같으면 숫자 id 오름차순이며, 같은 id가 여러 번 나오면 필터를 통과한 첫 행사를 유지합니다. 앞 모듈의 searchEvents는 필터 후 중복 제거라는 순서를 사용합니다. 입력 배열을 직접 sort하면 호출자의 데이터 순서도 바뀝니다. 반환 배열의 순서뿐 아니라 실행 전후 원본의 id 순서가 같은지도 별도 단언합니다.
실패 입력은 빈 결과로 숨기지 않습니다. 이 레슨의 브라우저 채점 과제는 JSON 객체의 events가 배열인지, q가 문자열인지, region이 all·seoul·busan 중 하나인지 검사합니다. 잘못된 계약은 약속한 ERROR 문자열로 출력합니다. 행사 내용이 없어서 []가 나온 경우와 배열 대신 객체를 받은 경우는 원인이 다르므로 출력에도 그 차이를 남깁니다.
테스트를 작성할 때 기대 값을 함수 실행 결과로 다시 계산하면 같은 결함을 복제할 수 있습니다. 작은 fixture의 id를 눈으로 따라가 기대 순서를 먼저 정합니다. 동률 날짜 두 건과 중복 한 건이면 계산이 충분히 드러납니다. 실제 행사 데이터를 내려받거나 계정을 연결할 필요가 없습니다. 입력을 고정하면 다른 사람이 같은 명령으로 같은 문제를 재현할 수 있습니다.
준비·행동·단언의 세 부분을 분리합니다. 준비에서 새로운 배열을 만들고, 행동에서 검색 함수를 한 번 호출하고, 단언에서 id와 원본 불변을 확인합니다. 여러 행동을 한 검사에 넣으면 어느 행동이 처음 어긋났는지 읽기 어렵습니다. 테스트 이름에도 “잘 된다” 대신 “날짜 동률에서 id 오름차순”처럼 조건과 결과를 함께 씁니다.
AssertionError를 만나면 Expected와 Actual을 읽습니다. 기대 [2]에 실제 [2,3]이 나왔다면 조건이나 중복 제거를 살펴봅니다. 순서만 다른 경우에는 날짜 비교와 동률 처리를 봅니다. JSON SyntaxError는 검색 정책보다 입력 형식 문제입니다. 코드 블록의 스마트 따옴표나 마지막 쉼표가 들어갔는지 확인하고, 채점기의 표준 입력에는 JSON 문서 한 개만 넣습니다.
검사 수가 많다고 검사 범위가 넓어지는 것은 아닙니다. 같은 검색어만 열 번 바꾼 경우는 빈 입력·복합 조건·동률·불변·계약 실패를 나눈 다섯 검사보다 놓치는 위험이 큽니다. 표에 위험, 입력, 기대 결과, 검사 경계를 적어 빈칸을 찾습니다. 모든 조합을 무작정 늘리기보다 실제 사용자 피해가 있는 조건을 우선하고 반례를 추가합니다.
단위 검사가 통과해도 CSS 넘침이나 Tab 순서를 확인했다고 말할 수 없습니다. Node의 최소 DOM 모델은 focus 호출로 activeElement 참조를 바꾸지만 레이아웃을 계산하지 않습니다. 반대로 브라우저 흐름 하나가 통과해도 모든 정렬 동률이 보호되지는 않습니다. 다음 레슨에서는 URL과 폼과 API를 묶어 단위 검사 밖의 연결 문제를 확인합니다.
실습은 표준 입력 JSON을 읽어 결과 id 배열 한 줄을 출력합니다. 화면 DOM이나 fetch는 사용하지 않습니다. 제공 starter에서 solve를 구현하고 테스트마다 입력을 독립적으로 실행합니다. 정상·공백·동률·중복·결과 없음·잘못된 지역이 모두 포함됩니다. 데이터 함수 전체를 서재 설명에서 옮기지 않고 이번 행사 계약에 필요한 계산만 직접 작성합니다.
완료 근거에는 통과한 경계와 아직 검사하지 않은 경계를 같이 적습니다. “검색 계산의 공백·복합 조건·정렬·입력 계약을 확인했다”는 문장은 여기서 뒷받침할 수 있습니다. “모바일 접근성이 완성됐다”는 문장은 이 실습에서 뒷받침할 수 없습니다. 더 읽기의 테스트 장은 비동기 검사와 디버깅 방법을 넓히는 자료이며, 이 레슨의 작은 테스트 표를 대신하지 않습니다.
따라하기
정상과 경계 기대를 분리합니다
demo.cjs에 저장해 실행합니다. 빈 검색과 없는 단어의 결과를 비교합니다.
const names=['마을 책 모임','그림 전시'];for(const q of [' ','없는행사'])console.log(JSON.stringify(names.filter(x=>x.includes(q.trim()))));실행 결과
["마을 책 모임","그림 전시"] []
동률과 원본 불변을 검사합니다
복사본만 정렬해 입력 순서를 보존합니다.
const assert=require('node:assert/strict');const rows=[{id:3,date:'2026-10-20'},{id:2,date:'2026-10-20'}];const ids=rows.map(x=>x.id);const sorted=[...rows].sort((a,b)=>a.date.localeCompare(b.date)||a.id-b.id);assert.deepEqual(rows.map(x=>x.id),ids);console.log(JSON.stringify(sorted.map(x=>x.id)));console.log(JSON.stringify(ids));실행 결과
[2,3] [3,2]
실패 메시지의 실제 값을 읽습니다
조건이 빠진 결과의 차이를 확인합니다. 예외를 잡아 두 값을 출력합니다.
const assert=require('node:assert/strict');try{assert.deepEqual([2,3],[2]);}catch(e){console.log(e.name);console.log('Actual='+JSON.stringify(e.actual));console.log('Expected='+JSON.stringify(e.expected));}실행 결과
AssertionError Actual=[2,3] Expected=[2]
확인 문제
실습
JSON 객체 events·q·region을 읽어 검색한 행사 id 배열을 한 줄로 출력합니다. q는 공백 정규화·영문 소문자 비교, region은 all이면 전체이고 나머지는 정확히 일치해야 합니다. 필터 뒤 같은 id의 첫 항목을 유지하고 날짜 문자열 오름차순·동률 id 오름차순으로 정렬합니다. 원본을 바꾸지 않습니다. events 배열·q 문자열·허용 지역을 순서대로 검사해 ERROR events, ERROR query, ERROR region을 출력합니다. 행사 필드는 양의 안전 정수 id·문자열 name·seoul 또는 busan인 region·YYYY-MM-DD 모양의 date이며 어긋나면 ERROR event입니다. 날짜는 실제 달력 유효성까지 검사하지 않고 모양만 검사합니다. JSON 해석 실패는 ERROR json입니다.
모범 답안
const fs=require('node:fs');
function solve(input){
if(!Array.isArray(input.events))return 'ERROR events';
if(typeof input.q!=='string')return 'ERROR query';
if(!['all','seoul','busan'].includes(input.region))return 'ERROR region';
if(input.events.some(e=>!e||!Number.isSafeInteger(e.id)||e.id<=0||typeof e.name!=='string'||!['seoul','busan'].includes(e.region)||typeof e.date!=='string'||!/^\d{4}-\d{2}-\d{2}$/.test(e.date)))return 'ERROR event';
const norm=x=>x.trim().replace(/\s+/g,' ').toLowerCase(),seen=new Set();
const rows=input.events.filter(e=>norm(e.name).includes(norm(input.q))&&(input.region==='all'||e.region===input.region)).filter(e=>{if(seen.has(e.id))return false;seen.add(e.id);return true;});
rows.sort((a,b)=>a.date.localeCompare(b.date)||a.id-b.id);
return JSON.stringify(rows.map(e=>e.id));
}
try{console.log(solve(JSON.parse(fs.readFileSync(0,'utf8'))));}catch{console.log('ERROR json');}
더 읽기
면접 질문
- 좁은 화면에서 레이아웃이 깨지는 문제의 확인 방법을 설명합니다.