Devin.KR

AI 개발 · 심화

AI 코딩 도구로 팀 개발하기 - Java 실무 저장소편

AI 에게 테스트부터 받기 - 그리고 그 테스트를 의심하기

실패하는 테스트로 요구를 고정, AI 가 만든 테스트의 함정(구현을 그대로 베낀 기대값, 항상 통과하는 테스트, 경계값 누락), 일부러 버그를 넣어 테스트가 잡는지 보는 변이 검사 원리, 완성 코드는 할인 계산 함수에 변이 3개를 넣어 테스트 묶음이 몇 개를 잡는지 출력한다

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서 팀은 주문 서비스의 변경을 PR 하나에 담을 크기로 잘랐다. 이번 작업은 주문 금액에 따라 할인액을 계산하는 함수 하나다. 범위가 작아졌다고 정답이 저절로 분명해지는 것은 아니다. “일정 금액 이상이면 할인한다”라는 문장에도 기준 금액 포함 여부, 원 미만 처리, 최대 할인액이라는 판단이 남아 있다. 이번에는 AI에게 구현보다 테스트를 먼저 요청하고, 그 테스트가 요구를 실제로 구별하는지 확인한다.

테스트 우선 개발(test-first development)에서 먼저 만드는 것은 초록색 통과 표시가 아니다. 요구를 어긴 구현을 거절할 수 있는 구체적인 예다. AI가 테스트와 구현을 함께 만들면 둘이 같은 오해를 공유할 수 있다. 따라서 테스트 파일이 생겼다는 사실과 요구를 검증했다는 사실을 나누어 보아야 한다.

  • 요구 문장을 입력과 기대 결과로 바꾸고, 실패하는 테스트로 구현할 동작을 고정한다.
  • 구현을 베낀 기대값, 항상 통과하는 조건, 빠진 경계값을 구별한다.
  • 일부러 넣은 오류를 테스트가 찾아내는 변이 검사 원리를 이해한다.
  • 외부 도구 없이 할인 함수의 변이 세 개를 실행하고 검출 개수를 확인한다.

문제 상황

작은 쇼핑몰 저장소에는 주문, 재고, 결제 모의 객체가 있다. 팀은 주문 총액에서 할인액을 빼고 남은 금액을 결제 모의 객체로 전달하려 한다. 이번 PR의 범위는 할인액 계산이다. 재고 차감이나 결제 호출 순서는 건드리지 않는다. 여러 동작을 한꺼번에 검사하면 실패했을 때 할인 규칙이 틀렸는지 주변 연결이 틀렸는지 구분하기 어려워진다.

담당자가 AI에게 “할인 계산과 테스트를 작성하라”고 요청하자 다음 답이 돌아왔다고 하자. 아래 답은 이 장을 위해 만든 가상 사례다.

주문 금액이 기준을 넘으면 할인하도록 구현했다. 일반 주문 금액으로 테스트했으며 계산 결과가 예상값과 같음을 확인했다. 테스트가 통과하므로 할인 계산을 사용할 수 있다.

말은 자연스럽지만 확인할 내용이 빠져 있다. 기준과 같은 금액도 할인되는가. 최대 할인액을 넘는 주문은 어떻게 되는가. “예상값”은 요구에서 구했는가, 방금 만든 함수를 다시 호출해서 얻었는가. 팀원이 테스트 이름만 보고 넘어가면 잘못된 구현과 잘못된 테스트가 함께 들어올 수 있다.

팀은 구현을 더 요청하기 전에 규칙을 아래처럼 정리한다. 여기의 수치는 예제 서비스 내부 규칙이며, 외부 서비스의 정책이나 통계를 뜻하지 않는다. 함수의 반환값은 최종 결제액이 아니라 할인액이다.

할인 함수의 요구와 독립적으로 계산한 기대 결과
항목합의한 규칙확인 예
입력 단위원 단위의 0 이상 정수0원의 할인액은 0원
할인 시작10,000원 이상부터 적용9,999원은 0원, 10,000원은 1,000원
할인 비율주문 금액의 10%, 원 미만 버림10,009원의 할인액은 1,000원
할인 한도최대 2,000원30,000원의 할인액은 2,000원
잘못된 입력음수이면 IllegalArgumentException 발생-1원은 계산 결과를 반환하지 않음

“이상”을 “초과”로 읽거나 할인액과 결제액을 혼동하면 구현은 짧아도 결과가 달라진다. 테스트를 먼저 받는 목적은 이런 해석 차이를 구현 전에 드러내는 데 있다. AI가 제안한 기대값도 사람이 이 표와 대조한다. 표 자체에 합의가 없다면 테스트를 늘려도 요구가 분명해지지 않는다.

실패하는 테스트로 요구를 고정한다

AI에게는 완성 함수를 바로 요청하기보다 입력과 기대 결과, 각 예가 필요한 이유를 먼저 요청한다. 이때 기대값을 구현 코드에서 가져오지 말라는 조건을 넣는다. 테스트 이름은 “할인 테스트”보다 “기준 금액에도 할인이 적용된다”처럼 확인하려는 규칙을 드러내는 편이 낫다.

할인 함수의 구현은 아직 작성하지 마라. 합의한 규칙에서 테스트 입력과 기대 할인액을 먼저 제안하라. 기준 직전·기준과 같은 값·할인 한도에 도달하는 값·한도를 넘는 값·음수를 포함하라. 기대값은 함수 호출로 구하지 말고 각 규칙에서 계산하라. 각 예가 어떤 오해를 막는지도 설명하라.

제안이 돌아오면 최소한 기준 금액의 예는 직접 계산해 본다. 10,000원은 할인 대상이고 그 10%는 1,000원이다. 구현이 아직 0만 반환한다면 이 테스트는 실패해야 한다. 반대로 빈 구현인데 통과한다면 테스트가 함수를 호출하는지, 기대값이 무엇인지부터 확인해야 한다.

처음의 실패는 아무 실패나 뜻하지 않는다. 파일명이 잘못되어 컴파일이 되지 않거나 실행할 클래스를 찾지 못하는 상태는 할인 요구를 검증한 결과가 아니다. 프로그램이 실행되고, 기준 금액을 넣었을 때 기대한 1,000원 대신 0원을 얻어 실패해야 한다. 실패 이유가 의도한 규칙과 연결되어야 한다.

이후 구현을 넣고 같은 입력과 같은 기대값으로 다시 실행한다. 구현을 넣는 동안 기대값까지 바꾸면 요구를 고정한 효과가 사라진다. 기대값을 수정할 수는 있지만, 그 근거는 구현이 내놓은 숫자가 아니라 팀이 다시 확인한 요구여야 한다. 요구가 바뀌었다면 무엇이 바뀌었는지도 함께 설명한다.

같은 입력과 기대값을 유지한 채 구현을 바꾸어야 실패에서 통과로 바뀐 이유를 설명할 수 있다

완성 프로그램의 첫 부분은 이 과정을 작은 형태로 재현한다. 할인 대상의 입력 10,000원과 기대 할인액 1,000원을 고정하고, 0만 반환하는 구현을 검사한다. 이때 출력되는 FAIL은 의도한 관찰이다. 이어서 정상 함수를 같은 입력으로 검사해 PASS를 출력한다. 두 함수의 결과가 다른 이유가 바로 이번에 추가한 할인 동작이다.

이 장에서 실패 표시를 남기는 이유는 테스트가 실제로 거절하는 모습을 보여 주기 위해서다. 팀 작업에서 매 실행마다 과거의 빈 구현을 유지할 필요는 없다. 요구를 구현하는 동안 확인한 실패와, 구현 후 같은 테스트가 통과했다는 사실이 중요하다.

AI가 만든 테스트도 검사의 대상이다

첫 번째 함정은 기대값이 구현과 같은 계산을 반복하는 경우다. 함수가 할인율을 5%로 잘못 적용했는데 테스트도 5%로 기대값을 계산하면 둘은 함께 통과한다. 함수 호출 결과를 기대값으로 다시 사용하는 경우는 더 직접적이다. 이런 검사는 요구와 구현을 비교하는 대신 구현을 자기 자신과 비교한다.

기대값을 언제나 숫자로 적어야 하는 것은 아니다. 큰 자료를 검사할 때는 별도의 계산 방식이나 일반적인 성질을 활용할 수도 있다. 다만 이 예제처럼 계산이 작을 때는 사람이 확인한 1,200원이라는 값이 읽기 쉽다. 계산식을 쓴다면 그 식이 어떤 요구를 표현하며 구현의 실수를 함께 복사하지 않았는지 설명할 수 있어야 한다.

두 번째 함정은 조건이 너무 약해 항상 통과하는 경우다. 할인액이 0 이상인지 확인하는 테스트는 음수 할인액을 거절하는 데는 의미가 있다. 하지만 모든 입력에 0을 반환하는 함수도 통과한다. “할인액은 음수가 아니다”와 “이 주문의 할인액은 1,200원이다”는 서로 다른 주장이다. 넓은 성질 하나가 구체적인 예를 대신하지는 않는다.

세 번째 함정은 평범한 입력만 모으는 경우다. 12,000원과 15,000원만 검사하면 기준을 포함하는지 알 수 없다. 두 입력 모두 할인 한도 아래이므로 한도를 삭제한 구현도 통과한다. 입력을 여러 개 썼다는 사실보다 서로 다른 판단 지점을 건드렸는지가 중요하다.

그럴듯한 테스트를 의심할 때 확인할 질문
함정통과해도 남는 의문보강 방법
구현을 베낀 기대값같은 계산 오류를 공유하지 않는가요구에서 손으로 확인한 값을 넣는다
너무 넓은 조건0만 반환해도 통과하지 않는가구체적인 할인액과 비교한다
경계값 누락기준 포함 여부와 한도를 구별하는가기준 직전·기준값·한도 초과를 넣는다
예외 발생만 확인원하지 않은 예외도 성공으로 세지 않는가약속한 예외 종류만 인정한다

테스트 검토에서는 질문을 뒤집어 보면 도움이 된다. “이 테스트가 통과하는가”에 이어 “어떤 잘못된 함수도 이 테스트를 통과할 수 있는가”를 묻는다. 모든 입력에 0을 반환하는 함수, 기준을 초과해야만 할인하는 함수, 한도 없이 할인하는 함수를 머릿속에 넣어 본다. 그중 하나라도 쉽게 통과한다면 해당 규칙의 근거가 아직 약하다.

일부러 틀려서 테스트의 구별 능력을 본다

변이 검사(mutation testing)는 정상 구현을 조금 바꾼 뒤 테스트가 그 차이를 찾아내는지 보는 방법이다. 여기서는 바뀐 구현 하나를 변이체라고 부른다. 정상 구현을 통과한 테스트가 변이체에서 실패하면 그 변이체를 검출한 것이다. 변이체에서도 모두 통과하면 살아남았다고 표현한다.

이 장에서는 세 가지 변이만 만든다. 기준 금액을 포함하지 않는 변이, 할인율을 5%로 바꾸는 변이, 최대 할인액 제한을 없애는 변이다. 각각 요구표의 서로 다른 규칙을 어긴다. 실행되지도 않는 코드로 바꾸는 대신, 프로그램은 실행되지만 잘못된 할인액을 반환하도록 만든다.

먼저 12,000원 한 건만 담은 약한 테스트 묶음을 사용한다. 정상 할인액은 1,200원이다. 할인율을 바꾸면 600원이 되어 잡히지만, 기준 포함 여부를 바꾸거나 한도를 없애도 이 입력의 결과는 1,200원이다. 따라서 이 묶음은 세 변이 중 하나만 검출한다.

보강한 묶음에는 0원, 기준 직전, 기준 금액, 원 미만이 생기는 금액, 일반 금액, 한도에 도달하는 금액, 한도를 넘는 금액, 음수를 넣는다. 기준 금액은 포함 여부를 바꾼 변이를 잡고, 한도를 넘는 금액은 제한을 없앤 변이를 잡는다. 일반 금액을 비롯한 여러 예가 할인율 변이를 잡는다.

정상 구현을 통과한 테스트가 세 변이체에서 실패하는지 비교하면 놓친 규칙을 찾을 수 있다

검출 개수는 준비한 오류에 대한 관찰 결과다. 세 개를 모두 잡았다고 다른 오류까지 없다고 결론 내릴 수는 없다. 예를 들어 원 미만을 버리지 않고 반올림하는 오류는 이번 세 변이에 포함되지 않는다. 보강한 묶음의 10,009원 사례는 그 오류도 드러내도록 골랐지만, 이번 검출 개수에는 별도 변이로 세지 않는다.

살아남은 변이체가 언제나 테스트 부족을 뜻하는 것도 아니다. 허용된 입력에서 정상 구현과 결과가 같은 변경이라면 테스트로 구별할 수 없다. 이 예제의 세 변이는 각각 결과가 달라지는 입력을 알고 있으므로 살아남은 이유를 구체적으로 설명할 수 있다. 숫자만 높이기보다 어떤 요구와 연결되는지 확인해야 한다.

정상 구현이 처음부터 테스트를 통과하는지도 선행 조건이다. 정상 구현에서도 실패하는 묶음으로 변이체를 검사하면, 원래 있던 실패를 변이 검출로 잘못 셀 수 있다. 완성 프로그램은 정상 구현의 여덟 사례가 모두 통과한 뒤에만 변이 검사를 진행한다.

완성 코드

아래 내용을 Main.java로 저장한다. 입력은 int로 받고 할인액은 long으로 반환한다. 곱셈에서 먼저 long으로 계산하므로 int 범위의 주문 금액을 10배 하더라도 계산이 넘치지 않는다. 파일, 시각, 난수, 외부 연결을 사용하지 않는다.

Main.java

public class Main {
    enum Mutation {
        NONE,
        STRICT_THRESHOLD,
        HALF_RATE,
        NO_CAP
    }

    @FunctionalInterface
    interface Discount {
        long apply(int amount);
    }

    record TestCase(
            String name,
            int amount,
            long expected,
            boolean expectsException) {
    }

    private static int failures;

    static long discount(int amount, Mutation mutation) {
        if (amount < 0) {
            throw new IllegalArgumentException("주문 금액은 음수일 수 없다");
        }

        boolean eligible = mutation == Mutation.STRICT_THRESHOLD
                ? amount > 10_000
                : amount >= 10_000;
        if (!eligible) {
            return 0L;
        }

        int rate = mutation == Mutation.HALF_RATE ? 5 : 10;
        long raw = amount * (long) rate / 100;
        return mutation == Mutation.NO_CAP
                ? raw
                : Math.min(raw, 2_000L);
    }

    static long unimplementedDiscount(int amount) {
        return 0L;
    }

    static boolean matches(Discount target, TestCase test) {
        try {
            long actual = target.apply(test.amount());
            return !test.expectsException()
                    && actual == test.expected();
        } catch (IllegalArgumentException exception) {
            return test.expectsException();
        }
    }

    static int failedCases(Discount target, TestCase[] tests) {
        int count = 0;
        for (TestCase test : tests) {
            if (!matches(target, test)) {
                count++;
            }
        }
        return count;
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            failures++;
        }
    }

    public static void main(String[] args) {
        TestCase threshold = new TestCase(
                "기준 금액", 10_000, 1_000L, false);

        System.out.println("== 실패에서 시작 ==");
        boolean before = matches(Main::unimplementedDiscount, threshold);
        System.out.println((before ? "PASS " : "FAIL ")
                + "구현 전: 입력=10000, 기대=1000, 실제="
                + unimplementedDiscount(10_000));

        Discount normal = amount -> discount(amount, Mutation.NONE);
        check(!before, "빈 구현을 테스트가 거절한다");
        check(matches(normal, threshold),
                "구현 후: 입력=10000, 기대=1000, 실제="
                        + normal.apply(10_000));

        TestCase[] strong = {
            new TestCase("0원", 0, 0L, false),
            new TestCase("기준 직전", 9_999, 0L, false),
            threshold,
            new TestCase("원 미만 버림", 10_009, 1_000L, false),
            new TestCase("일반 금액", 12_000, 1_200L, false),
            new TestCase("한도 도달", 20_000, 2_000L, false),
            new TestCase("한도 초과", 30_000, 2_000L, false),
            new TestCase("음수 거절", -1, 0L, true)
        };
        TestCase[] weak = {
            new TestCase("일반 금액", 12_000, 1_200L, false)
        };

        System.out.println("== 정상 구현 확인 ==");
        int normalFailures = 0;
        for (TestCase test : strong) {
            boolean passed = matches(normal, test);
            check(passed, test.name());
            if (!passed) {
                normalFailures++;
            }
        }
        if (normalFailures != 0) {
            throw new IllegalStateException("정상 구현부터 확인해야 한다");
        }

        System.out.println("== 변이 검사 ==");
        Mutation[] mutations = {
            Mutation.STRICT_THRESHOLD,
            Mutation.HALF_RATE,
            Mutation.NO_CAP
        };
        int weakDetected = 0;
        int strongDetected = 0;

        for (Mutation mutation : mutations) {
            Discount changed = amount -> discount(amount, mutation);
            boolean caughtByWeak = failedCases(changed, weak) > 0;
            boolean caughtByStrong = failedCases(changed, strong) > 0;

            if (caughtByWeak) {
                weakDetected++;
            }
            if (caughtByStrong) {
                strongDetected++;
            }
            System.out.println(mutation.name()
                    + ": 약한 묶음=" + (caughtByWeak ? "검출" : "생존")
                    + ", 보강 묶음=" + (caughtByStrong ? "검출" : "생존"));
        }

        System.out.println("약한 묶음: " + weakDetected + "/3 검출");
        System.out.println("보강 묶음: " + strongDetected + "/3 검출");
        check(weakDetected == 1, "약한 묶음은 변이 1개를 잡는다");
        check(strongDetected == 3, "보강 묶음은 변이 3개를 잡는다");
        System.out.println("최종 점검 실패: " + failures);

        if (failures != 0) {
            throw new IllegalStateException("점검 실패가 있다");
        }
    }
}

줄별 해설

Mutation 선언은 정상 계산과 세 가지 오류를 이름으로 구분한다. NONE은 비교 기준이며 변이 개수에 넣지 않는다. 실제 주문 코드에 이런 선택지를 둘 필요는 없다. 이 프로그램은 서로 다른 구현을 짧은 코드로 비교하기 위해 같은 함수 안에서 오류를 선택한다.

Discount는 int 하나를 받아 long을 반환하는 함수 형태다. matches는 검사 대상이 정상 구현인지 변이체인지 알 필요 없이 이 형태로 호출한다. 메서드 참조와 람다식은 각 구현을 같은 검사기에 전달하는 데 쓰인다. 검사기 자체를 변이마다 복사하지 않으므로 기대값도 한곳에 유지된다.

TestCase는 이름, 입력, 기대 할인액, 예외 기대 여부를 묶는다. 음수 사례의 expected에 있는 0은 사용되지 않는다. 예외를 기대하는 사례에서는 값을 반환한 것 자체가 실패다. 별도 표시를 둔 이유는 할인액 0원과 계산 거절을 구분하기 위해서다.

discount의 첫 조건은 음수를 거절한다. 이어지는 eligible 계산에서 정상 구현은 기준 금액을 포함하고 STRICT_THRESHOLD만 포함하지 않는다. rate 줄에서는 HALF_RATE만 5를 사용한다. 마지막 반환문에서는 NO_CAP만 한도 적용을 건너뛴다. 각 변이는 한 종류의 규칙을 바꾸므로 어느 입력에서 검출되는지 추적하기 쉽다.

raw 계산의 곱셈에는 long 변환이 들어 있다. 양수 정수끼리 나눈 결과에서 소수 부분이 버려지므로 원 미만 버림 규칙에 맞는다. 음수는 앞에서 거절했으므로 음수 나눗셈의 처리 방향을 이 규칙과 섞어 해석할 필요가 없다.

matches는 정상 반환과 예외 발생을 나누어 검사한다. 정상 반환에서는 예외를 기대하지 않았는지와 값이 같은지를 함께 본다. IllegalArgumentException만 잡기 때문에 다른 종류의 예외가 나면 성공으로 숨기지 않고 실행을 중단한다. 예외 메시지는 이번 합의에 포함하지 않았으므로 문자열까지 비교하지 않는다.

failedCases는 실패한 사례 수를 돌려준다. 변이체에서 하나라도 실패하면 해당 변이를 검출한 것으로 센다. 테스트 세 개가 같은 변이를 잡더라도 검출한 변이는 한 개다. 사례 수와 변이 수는 서로 다른 숫자다.

check는 결과를 출력하고 최종 점검 실패 수를 모은다. 처음의 빈 구현에 대한 FAIL은 check 대신 직접 출력한다. 그 실패는 의도한 시연이기 때문이다. 이어지는 check는 빈 구현을 실제로 거절했는지 검사하므로 PASS가 되어야 한다. 예상한 실패와 프로그램 자체의 점검 실패를 구분한 구성이다.

main의 strong 배열은 요구에서 구한 고정 기대값을 담는다. weak 배열은 일반 금액 한 건만 담는다. 정상 구현을 확인한 뒤 같은 세 변이에 두 묶음을 각각 적용한다. 마지막의 두 check는 이 예제에서 예상한 검출 개수가 나오는지 확인하는 장치이며, 할인 규칙을 검사하는 여덟 사례를 대신하지 않는다.

실행 결과

Main.java가 있는 디렉터리에서 다음 명령을 순서대로 실행한다. JDK 17의 기본 컴파일러와 실행기만 사용한다.

javac -d out *.java
java -cp out Main

예상 출력은 다음과 같다. 첫 FAIL은 아직 구현하지 않은 함수가 기준 금액의 요구를 만족하지 못한다는 뜻이다. 뒤의 최종 점검 실패 수는 0이다.

== 실패에서 시작 ==
FAIL 구현 전: 입력=10000, 기대=1000, 실제=0
PASS 빈 구현을 테스트가 거절한다
PASS 구현 후: 입력=10000, 기대=1000, 실제=1000
== 정상 구현 확인 ==
PASS 0원
PASS 기준 직전
PASS 기준 금액
PASS 원 미만 버림
PASS 일반 금액
PASS 한도 도달
PASS 한도 초과
PASS 음수 거절
== 변이 검사 ==
STRICT_THRESHOLD: 약한 묶음=생존, 보강 묶음=검출
HALF_RATE: 약한 묶음=검출, 보강 묶음=검출
NO_CAP: 약한 묶음=생존, 보강 묶음=검출
약한 묶음: 1/3 검출
보강 묶음: 3/3 검출
PASS 약한 묶음은 변이 1개를 잡는다
PASS 보강 묶음은 변이 3개를 잡는다
최종 점검 실패: 0

약한 묶음은 정상 구현에서 통과한다. 그러나 두 변이에서도 통과한다. 이 차이가 핵심이다. 정상 구현을 통과한다는 사실만으로 테스트의 구별 능력을 판단할 수 없다. 보강한 묶음은 이번에 준비한 세 가지 오류를 모두 거절한다. 결과를 설명할 때는 “검사한 세 변이를 모두 잡았다”처럼 범위를 붙인다.

실무에서 자주 틀리는 것

다음 세 예는 테스트 자체를 고치는 경우다. 각 코드에는 잘못된 검사인 wrongTest와 보강한 검사인 fixedTest가 함께 있다. 실행할 수 있는 독립 클래스이며, 각 클래스 이름과 같은 이름의 파일로 저장할 수 있다. PASS는 그 메서드의 조건이 참이었다는 뜻일 뿐이다. 잘못된 구현을 통과시킨 PASS도 있으므로 출력 문구를 함께 읽어야 한다.

기대값을 검사 대상에서 다시 얻는다

잘못된 테스트는 할인 함수를 두 번 호출해 서로 비교한다. 할인율이 5%로 틀려도 같은 결과끼리 비교하므로 통과한다. 고친 테스트는 같은 12,000원을 넣되 요구에서 계산한 1,200원과 비교한다. 바뀐 것은 주문 입력이나 구현이 아니라 기대값의 근거다.

class CopiedExpectationExample {
    static long discount(int amount) {
        return amount * 5L / 100;
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    static void wrongTest(int amount) {
        long expected = discount(amount);
        check(discount(amount) == expected, "잘못된 검사");
    }

    static void fixedTest(int amount) {
        check(discount(amount) == 1_200L, "고친 검사");
    }

    public static void main(String[] args) {
        int amount = 12_000;
        System.out.println("입력=12000, 실제=" + discount(amount));
        wrongTest(amount);
        fixedTest(amount);
    }
}

실행하면 실제 할인액은 600원이고 잘못된 검사는 PASS, 고친 검사는 FAIL을 출력한다. 두 검사가 받은 입력과 함수의 반환값은 같다. 고친 검사의 실패는 테스트가 나빠졌다는 뜻이 아니라 기존 검사가 숨기던 할인율 오류를 드러냈다는 뜻이다.

성질 하나만 확인하고 구체적인 결과를 생략한다

다음 잘못된 검사는 할인액이 음수가 아닌지만 본다. 이 성질은 유지할 수 있지만 이것만으로 할인 기능을 검사했다고 보기는 어렵다. 고친 검사는 같은 입력에 대해 구체적인 할인액을 요구한다. 모든 입력에 0을 반환하는 구현을 거절할 수 있다.

class AlwaysPassExample {
    static long discount(int amount) {
        return 0L;
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    static void wrongTest(int amount) {
        check(discount(amount) >= 0L, "잘못된 검사");
    }

    static void fixedTest(int amount) {
        check(discount(amount) == 1_200L, "고친 검사");
    }

    public static void main(String[] args) {
        int amount = 12_000;
        System.out.println("입력=12000, 실제=" + discount(amount));
        wrongTest(amount);
        fixedTest(amount);
    }
}

실행하면 실제 할인액은 0원이고 잘못된 검사는 PASS, 고친 검사는 FAIL을 출력한다. 넓은 성질과 구체적인 기대값은 함께 사용할 수 있다. 다만 성질 검사를 여러 개 추가했다고 해서 빠진 구체적 요구가 자동으로 채워지는 것은 아니다.

일반 입력이 통과하자 경계 검사를 끝낸다

아래 함수는 기준을 초과해야만 할인한다. 잘못된 테스트의 12,000원 입력으로는 이 오류가 드러나지 않는다. 고친 테스트에서도 같은 입력의 결과가 1,200원인지 먼저 확인한다. 여기에 기준 금액 10,000원을 더해, 기존에 확인한 동작과 새로 확인할 경계를 함께 보여 준다.

class BoundaryExample {
    static long discount(int amount) {
        return amount > 10_000
                ? Math.min(amount * 10L / 100, 2_000L)
                : 0L;
    }

    static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
    }

    static void wrongTest() {
        check(discount(12_000) == 1_200L, "일반 입력만 검사");
    }

    static void fixedTest() {
        check(discount(12_000) == 1_200L, "기존 입력 유지");
        check(discount(10_000) == 1_000L, "기준 금액 추가");
    }

    public static void main(String[] args) {
        System.out.println("입력=12000, 실제=" + discount(12_000));
        wrongTest();
        fixedTest();
    }
}

실행하면 12,000원의 실제 할인액은 두 검사에서 모두 1,200원이며 앞의 두 확인은 PASS다. 기준 금액을 추가한 확인만 FAIL이다. 이후 함수의 비교를 기준 포함으로 고치면 기존 입력은 계속 1,200원을 반환하고 기준 금액의 할인액은 0원에서 1,000원으로 바뀐다. 완성 프로그램의 정상 구현과 STRICT_THRESHOLD 변이가 이 차이를 그대로 실행해 확인한다.

경계값을 고를 때는 작은 수와 큰 수를 임의로 하나씩 고르는 데서 멈추지 않는다. 조건이 바뀌는 위치를 찾고 그 직전과 해당 위치를 비교한다. 한도 역시 한도에 딱 도달하는 주문만으로는 제한 삭제를 잡지 못한다. 한도를 넘어서는 주문이 있어야 반환값의 차이가 생긴다.

한눈에 보기

테스트를 먼저 받고도 다시 의심해야 하는 이유
확인 단계해야 할 일얻는 근거
요구 정리단위, 기준 포함, 버림, 한도, 예외를 적는다기대값을 판단할 공통 규칙
첫 실패빈 구현에서 의도한 값 비교가 실패하는지 본다테스트가 빠진 동작을 거절한다는 증거
구현 후 실행같은 입력과 기대값으로 다시 검사한다고정한 예를 구현이 만족한다는 증거
테스트 검토자기 비교, 약한 조건, 경계 누락을 찾는다통과 표시가 숨긴 빈틈
변이 검사알려진 오류를 넣고 검출 여부를 센다선택한 오류를 구별하는 능력

AI에게 테스트를 맡긴다는 것은 기대값의 책임까지 넘긴다는 뜻이 아니다. AI는 사례를 빠르게 제안하고 누락 후보를 찾는 데 도움을 줄 수 있다. 팀은 각 기대값의 근거와 실패 이유를 확인한다. 이렇게 확인한 테스트를 다음 장에서 PR의 변경 내용을 읽을 때 근거로 삼는다. 통과 여부와 함께 무엇을 확인했는지 설명할 수 있어야 한다.

연습 문제

  1. 완성 프로그램의 strong 배열에서 “기준 금액” 사례만 제거했다고 하자. 나머지 입력을 바꾸지 않았을 때 STRICT_THRESHOLD 변이를 검출할 수 있는지 설명하라. main 앞부분의 별도 시연이 아니라 변이 검사에 전달하는 배열만 기준으로 판단하라.
  2. 최대 할인액 제한을 없앤 변이를 잡으려고 20,000원 사례만 사용했다. 이 사례로 부족한 이유와 추가할 입력·기대값 한 쌍을 적어라.
  3. 10,009원의 할인액을 원 미만 버림 대신 가장 가까운 원으로 반올림하는 네 번째 변이를 생각하라. 기존 보강 묶음이 잡는지 계산하고, 기존 세 변이의 검출 개수와 어떻게 구분해서 보고할지 적어라.
  4. 음수 테스트에서 모든 예외를 성공으로 인정하도록 matches를 바꾸었다. 이 변경이 왜 요구 검증을 약하게 만드는지 설명하고, 정상 반환과 약속한 예외, 다른 예외를 각각 어떻게 처리할지 적어라.

정답과 해설

  1. 검출하지 못한다. 남은 입력 중 10,000원과 같은 값이 없기 때문이다. 9,999원은 두 구현 모두 할인하지 않고, 10,009원 이상인 입력은 두 구현 모두 할인한다. 음수도 두 구현에서 같은 예외가 발생한다. 별도 시연이 오류를 보여 주더라도 해당 배열의 검출 능력으로 합쳐 세어서는 안 된다.
  2. 20,000원의 10%는 2,000원이므로 한도 적용 여부와 무관하게 결과가 같다. 30,000원에 기대 할인액 2,000원을 추가하면 정상 구현은 2,000원, 제한을 없앤 변이는 3,000원을 반환하여 구별된다. 한도 도달 사례와 한도 초과 사례의 역할이 다르다.
  3. 기존의 10,009원 사례가 잡는다. 할인율을 적용한 값은 1,000.9원이므로 버리면 1,000원이고 반올림하면 1,001원이다. 네 번째 변이를 실제 검사 대상에 추가한 뒤에는 전체 네 변이 중 검출 개수를 별도로 보고한다. 실행하지 않은 변이를 기존의 3/3 결과에 포함하거나, 사례를 하나 더 넣은 것을 변이 하나를 더 검사한 것처럼 세지 않는다.
  4. 음수에 대해 우연히 발생한 다른 예외도 성공으로 처리하면 입력 거절 규칙을 확인하지 못한다. 예외를 기대한 사례에서 정상 반환하면 실패이고, 약속한 IllegalArgumentException이 발생하면 성공이다. 다른 예외는 실패로 보고하거나 실행을 중단해 드러내야 한다. 완성 프로그램은 약속한 예외만 잡고 나머지는 밖으로 전달한다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.