Devin.KR

요청 뒤 실제 데이터 확인

110분 안팎

학습 목표

응답의 성공과 DB 변경의 일치를 확인합니다.

개념

가입 성공 응답과 회원 저장은 서로 다른 증거입니다

HTTP 201과 result:'VALID'를 보낸 앱이 실제로 회원을 저장하지 않을 수 있습니다. 중복 가입을 409로 거절하고도 새 행을 남길 수도 있습니다. 응답 본문 count를 믿고 저장 결과를 판정하면 응답을 만든 코드와 같은 오류를 공유할 가능성이 있습니다. 이번 레슨은 요청 전후의 SQL 조회를 별도 관찰로 사용하여 성공 가입의 1행 증가와 거절 요청의 무변경을 검증합니다. QA의 검증 경계를 응답에서 실제 데이터로 한 층 확장합니다.

실습 앱의 members는 email 기본키, secret 비밀번호 표현, nickname 필드로 구성됩니다. JdbcTemplate은 제공 H2에 조회문과 매개변수를 전달합니다. 학습자는 앱의 저장 코드를 다시 작성하지 않고 src/test/java/lab/StorageOracle.java의 단언을 완성합니다. 데이터 스냅샷은 email과 nickname을 ORDER BY email로 조회한 행 목록입니다. 순서 없는 SQL 결과를 목록 그대로 비교하면 같은 데이터인데 순서만 달라 실패할 수 있어 비교 순서를 명시합니다.

요청 전의 상태를 먼저 확보합니다

성공 가입 테스트는 기존 회원 한 명을 준비하고 before를 조회합니다. new@example.test 가입 요청을 보낸 다음 after를 다시 조회합니다. before와 after를 같은 객체로 재사용하지 않습니다. 응답 뒤에 조회한 스냅샷을 before로 이름 붙이면 변화량을 알 수 없습니다. 준비, 전 관찰, 요청, 후 관찰, 단언 순서를 코드로 드러내면 어떤 데이터 변경을 비교하는지 동료가 빠르게 이해합니다.

회원 수 증가량은 after.size() - before.size()가 1이어야 합니다. 고정 전체 수 1을 기대하면 기존 회원이 있는 정상 가입을 실패시킵니다. 반대로 1행 증가만 확인하면 다른 이메일이나 잘못된 닉네임으로 저장한 결함을 놓칩니다. after에는 요청한 이메일과 닉네임의 행이 정확히 하나 있어야 하며 before의 기존 행도 그대로 포함되어야 합니다. 수량과 식별자·내용·기존 행 보존을 함께 확인합니다.

SQL WHERE email=?는 데이터 값을 바인딩하는 방식입니다. 테스트 데이터의 이메일을 문자열 이어붙이기로 SQL 문장에 넣지 않습니다. 실습 코드의 계정은 합성 값이지만 검증 코드의 습관도 실제 작업과 연결됩니다. COUNT(*)는 해당 테이블의 행 수를 세며 조회 대상과 조건이 잘못되면 정확한 숫자도 틀린 증거가 됩니다. 전체 변화와 특정 회원 확인의 목적을 구분하여 필요한 쿼리를 선택합니다.

중복 거절의 무변경을 정의합니다

같은 이메일로 다시 가입할 때는 409와 DUPLICATE를 기대합니다. 그 뒤 before와 after의 행 목록 전체가 같아야 합니다. 회원 수만 같다고 기존 닉네임이 보존되었다고 볼 수 없습니다. 앱이 새 행 대신 기존 회원을 덮어쓰면 개수는 같지만 인수 기준을 위반합니다. 이번 스냅샷은 회원 식별자와 닉네임을 비교하며 비밀번호 표현의 무변경까지 자동 검증한 범위는 아닙니다. 관찰한 열과 제외한 열을 명확히 말합니다.

assertEquals(before, after, "거절: 전체 회원 정보 무변경")는 요청 전후 목록을 비교합니다. 이 비교는 조회한 열 목록의 동일성을 확인합니다. 개수, 이메일 집합, 닉네임 값이 모두 같아야 통과합니다. 검증 함수는 DB를 수정하거나 after를 before에 맞추지 않습니다. 거절 뒤 불필요한 행을 정리하고 나서 비교하면 이미 발생한 결함 증거를 지우므로 요청 직후에 관찰합니다.

권한 거절은 타인의 데이터를 지켜야 합니다

A와 B를 준비하고 A로 로그인한 다음 B 닉네임 변경을 요청합니다. 응답은 403이며 B의 nickname은 기존 값이어야 합니다. 익명 요청도 401과 무변경을 기대합니다. 로그인 단계에서 사용한 세션은 MockHttpSession으로 후속 MockMvc 요청에 연결합니다. 실제 TCP 쿠키 전달은 이 테스트로 증명하지 않지만 세션 속성과 컨트롤러 권한 분기를 실제로 거칩니다. 외부 curl·fetch 증거와 역할이 다릅니다.

본인 수정은 200과 UPDATED뿐 아니라 SQL 재조회한 A 닉네임이 새이름인지 확인합니다. B 닉네임과 전체 회원 수는 유지되어야 합니다. 대상 한 행만 업데이트하는 요구를 전체 행 수 검사로 대신하지 않습니다. UPDATE의 조건이 빠져 A와 B 모두 바뀌는 결함은 개수가 그대로여서 수량 검사로 놓칩니다. 누구의 어떤 열이 바뀌어야 하는지 요구사항에 맞춰 좁힌 단언이 필요합니다.

결함 주입으로 저장 검증기의 민감도를 확인합니다

rejected-write 변형은 중복 요청에 409를 반환하면서 ghost@example.test를 추가합니다. 응답 상태를 확인하는 테스트는 통과할 수 있지만 SQL 스냅샷은 달라집니다. unchanged가 AssertionError를 던지는 것을 assertThrows로 확인합니다. DETECTED 메시지는 이 변형을 검증기가 발견했다는 증거입니다. 앱이 거절 처리를 정상 수행했다는 의미로 보고하지 않습니다. 정상 거절과 결함 거절을 같은 단언으로 비교합니다.

forbidden-write 변형은 A의 타인 수정 요청에 403을 보내면서 B의 닉네임을 변경합니다. before와 after의 크기는 여전히 2입니다. 테스트는 먼저 두 크기가 같은지 확인하여 단순 수량 검사로는 놓치는 조건임을 증명하고, 그 다음 전체 스냅샷 비교가 실패하는지 확인합니다. 이 사례를 통과시키려면 unchanged에 내용 비교가 있어야 합니다. starter의 빈 함수는 Missing expected exception과 유사한 예상 오류 부재로 실패합니다.

실제 통합 범위와 격리 범위를 구분합니다

StorageTest는 Spring Boot를 웹 포트 없는 모드로 시작하고 실제 JdbcTemplate과 H2 DB를 사용합니다. MockMvc는 요청 매핑·JSON 변환·컨트롤러를 거치므로 메서드만 직접 호출한 단위 검사보다 넓은 범위입니다. 각 테스트마다 새 Spring context와 독립 이름의 H2를 만들고 종료합니다. 이전 문서의 15개 blocked를 자동 통과로 바꾸거나 실제 브라우저 검증을 했다고 쓰지 않습니다. 이번에 확인한 조건을 새 증거로 추가합니다.

교육 앱은 매 요청의 저장을 단순화하여 제공하며 운영 DB나 파일 영속성을 사용하지 않습니다. 동시 가입에서 같은 이메일 요청이 경합하는 상황, 실제 DB 격리 수준과 복구, 재시작 뒤 영속성은 이번 검사 대상이 아닙니다. 현재 정상 흐름에 트랜잭션 단어가 있다는 이유만으로 원자성과 동시성까지 검증했다고 주장하지 않습니다. 다음 데이터 격리 모듈은 이번 미션의 api-app과 생성된 검증 코드에서 출발합니다.

Java 단언 실패를 읽고 해결합니다

starter의 created는 before.size()와 after.size()가 같다고 잘못 기대합니다. 성공 가입 테스트의 실제 2와 기대 1 차이를 읽고 요구사항의 증가량 1로 고칩니다. unchanged가 아무것도 하지 않으면 두 결함 검출 테스트에서 Expected java.lang.AssertionError to be thrown 같은 메시지가 나옵니다. 오류가 없어 보이는 함수가 잘못된 데이터를 허용하고 있다는 뜻입니다. 정상 무변경 검사만 보고 함수가 완성됐다고 판단하지 않습니다.

Tests run, Failures, Errors, Skipped를 함께 읽습니다. Failures는 단언 불일치, Errors는 실행 환경이나 요청 처리 예외 등을 먼저 조사합니다. DependencyResolutionException은 의존성 캐시와 오프라인 실행 환경을 점검합니다. Address already in use나 bind 실패는 외부 네트워크 검사에서 포트 조건을 확인합니다. 저장 레슨은 포트를 열지 않으므로 그런 실패를 제품 저장 결함으로 처리하지 않습니다.

미션에서 이전 검증과 새 증거를 묶습니다

미션은 m04 solution을 그대로 담고 이전 check.sh와 README를 m04-check.sh·m04-README.md로 보존합니다. m05-check-inherited.py는 모든 이전 파일의 해시를 비교합니다. 새 api-app의 validateHttp는 m04의 validateResponse를 복사한 부품을 재사용합니다. 기존 checks의 회귀 검사 31개와 새 MockMvc 응답 검사 14개, 저장 검사 6개를 함께 실행합니다. curl·fetch 두 검사는 별도 외부 실행 명령으로 남습니다.

완료 보고에는 전후 회원 수, 대상 이메일의 닉네임, 거절의 무변경과 두 저장 결함의 검출을 포함합니다. SQL 증거가 응답 count와 독립인지 설명하고 스냅샷이 어느 열을 관찰했는지 말합니다. 미션의 초록 결과는 지정한 API 계약과 저장 조건의 증거입니다. 실제 사용자 화면과 영구 DB, 동시성과 세션 만료까지 확인한 것처럼 확대하지 않으면 다음 모듈이 남은 위험을 정확히 이어받을 수 있습니다.

따라하기

수량 검사의 한계를 실행합니다

회원 수가 같아도 타인의 닉네임 변경이 있습니다. 아래 출력은 합성 스냅샷의 비교 결과입니다.

const before=[{email:'b@example.test',nickname:'기존'}];
const after=[{email:'b@example.test',nickname:'침범'}];
console.log('same-count',before.length===after.length);
console.log('same-content',JSON.stringify(before)===JSON.stringify(after));

실행 결과

same-count true
same-content false

성공 가입 오라클을 완성합니다

StorageOracle.java의 created에서 증가량과 기존 행·새 행 값을 단언합니다. 아래는 메서드 안에 넣는 Java 코드이며 이름은 제공 매개변수와 같습니다.

assertEquals(before.size()+1, after.size(), "가입: 1행 증가");
assertTrue(after.containsAll(before), "기존 행 보존");
assertEquals(1, after.stream().filter(row -> email.equals(row.get("EMAIL")) && nickname.equals(row.get("NICKNAME"))).count(), "가입: 입력값 저장");

거절의 내용 무변경을 완성합니다

StorageOracle.java의 unchanged에서 요청 직전과 직후의 정렬된 목록을 비교합니다. 같은 행 수의 닉네임 변경도 실패해야 합니다.

assertEquals(before, after, "거절: 전체 회원 정보 무변경");

저장 통합 검사를 실행합니다

qa-storage-effect 루트에서 Maven wrapper로 실행합니다. 저장 검사 6개와 실패 0을 surefire 보고서로 확인합니다. 이 명령은 실제 컨트롤러와 H2를 거치며 포트를 열지 않습니다.

./mvnw test

확인 문제

실습

src/test/java/lab/StorageOracle.java의 created·unchanged를 완성합니다. 성공 가입의 1행 증가·새 값·기존 행 보존과 중복·권한 거절의 스냅샷 무변경을 검사합니다. 두 결함 변형을 AssertionError로 검출하고 본인 수정 SQL 조회도 통과해야 합니다.

시작 코드·테스트 내려받기

실행 명령

./mvnw test

기대 결과

StorageTest 6개, Failures 0, Errors 0, Skipped 0; 저장 변형 2개 검출

모범 답안모범 답안 내려받기

더 읽기

면접 질문

  • API 응답 코드가 성공이어도 테스트가 실패할 수 있는 상황을 설명해 주시면 됩니다.