Devin.KR

단위 테스트 - 검사 가능한 코드 만들기

개발자KR 조회 0

이 장에서 배우는 것

도서관의 연체료 계산 규칙을 바꾼 뒤 프로그램을 실행해 숫자 하나를 확인하는 것만으로는 변경이 올바른지 판단하기 어렵다. 유예 기간의 마지막 날, 요금이 처음 생기는 날, 상한액에 도달하는 날은 서로 다른 오류를 드러낸다. 이런 사례를 코드로 남기면 규칙을 수정할 때마다 같은 조건을 다시 확인할 수 있다.

단위 테스트(unit test)는 작은 코드 단위에 정해진 입력을 주고 실제 결과가 기대한 결과와 일치하는지 검사하는 방법이다. 이 장에서는 외부 패키지 없이 콘솔 검사 함수를 만든다. 앞 장에서 다룬 의존성 분리의 관점을 이어 받아, 화면 출력과 연체료 계산을 분리하고 계산 규칙을 독립적으로 확인한다.

  • 콘솔 검사 함수로 성공과 실패를 구분하고 실행 결과를 집계한다.
  • 준비·실행·확인 구조로 검사 사례의 의도를 드러낸다.
  • 경계값과 잘못된 입력을 포함해 연체료 규칙을 확인한다.
  • 결정적인 결과를 만드는 코드와 검사하기 어려운 코드를 구별한다.
  • xUnit 테스트 프로젝트로 옮길 때 필요한 구성과 역할을 이해한다.

문제 상황

작은 동네 도서관은 반납이 늦어진 도서에 연체료를 부과한다. 담당자는 처음 이틀을 유예 기간으로 두고, 그다음 날부터 하루에 300원씩 받기로 했다. 한 건의 대출에 부과하는 연체료는 1,500원을 넘지 않는다. 여기서 연체 일수는 이미 계산된 정수이며, 반납 예정일과 실제 반납일을 비교하는 일은 이번 코드의 책임에 포함하지 않는다.

처음 작성한 프로그램은 연체 일수를 입력받아 계산 결과를 출력했다. 담당자가 연체 5일을 입력했을 때 900원이 나왔으므로 개발자는 계산이 맞다고 생각했다. 그러나 이 결과만으로는 연체 2일에도 요금을 부과하는 실수나, 상한액을 넘긴 금액을 그대로 반환하는 실수를 찾아낼 수 없다. 우연히 정상으로 보이는 입력 하나가 전체 규칙을 대표하지는 않는다.

운영 중에는 유예 기간이나 하루 요금이 바뀔 수 있다. 이때 이전에 확인한 조건을 기억에 의존해 다시 입력하면 일부 사례를 빠뜨리기 쉽다. 검사 사례를 실행 가능한 코드로 남겨 두면 변경 전후에 동일한 기준을 적용할 수 있다. 실패한 사례의 이름은 어느 규칙이 영향을 받았는지 알려 준다.

이번 장의 계산기는 생성할 때 유예 일수, 하루 요금, 상한액을 받는다. 계산 메서드는 연체 일수를 받아 금액만 반환한다. 콘솔 입력, 현재 날짜, 파일 접근은 필요하지 않다. 이렇게 책임을 좁히면 검사에 필요한 준비도 작아지고, 같은 입력을 반복했을 때 결과가 달라질 이유도 줄어든다.

검사 가능한 코드와 준비·실행·확인

검사 가능한 코드는 실행에 필요한 조건을 호출하는 쪽에서 마련할 수 있고, 결과를 직접 관찰할 수 있는 코드다. 연체료 계산기가 내부에서 현재 날짜를 읽거나 사용자에게 입력을 요구한다면 검사 실행마다 조건을 통제해야 한다. 반대로 연체 일수와 요금 규칙을 명시적으로 전달하면 검사 함수가 필요한 조건을 모두 정할 수 있다.

앞 장에서 의존성을 외부에서 전달한 이유도 이러한 통제와 연결된다. 다만 모든 클래스에 인터페이스를 추가해야 검사할 수 있는 것은 아니다. 이번 계산기는 외부 서비스에 접근하지 않으므로 실제 객체를 그대로 만든다. 검사에 필요하지 않은 계층을 늘리기보다 입력과 반환값의 관계를 분명히 하는 데 집중한다.

각 검사 사례는 준비·실행·확인(arrange-act-assert) 순서로 읽히도록 작성한다. 준비에서는 계산기를 만들고 입력을 정한다. 실행에서는 확인하려는 메서드를 호출한다. 확인에서는 반환값을 미리 정한 기대값과 비교한다. 이 구조는 문법상의 규칙이 아니라 검사 의도를 읽기 쉽게 만드는 작성 방식이다.

검사는 조건을 준비하고 계산을 실행한 뒤 기대값과 실제값을 비교한다
연체 3일 검사에서 각 단계가 맡는 일
단계작성할 내용이 사례의 값
준비규칙과 입력을 고정한다.유예 2일, 하루 300원, 연체 3일
실행검사 대상 메서드를 호출한다.Calculate(3)
확인기대값과 반환값을 비교한다.기대 금액 300원

기대값은 업무 규칙을 읽고 독립적으로 정해야 한다. 검사 안에서 계산기의 공식과 똑같은 공식을 다시 작성하면 두 곳이 같은 실수를 공유할 수 있다. 이번 예제는 연체 3일의 기대값을 300원으로 직접 적는다. 이 숫자는 구현을 따라 계산한 결과가 아니라, 이틀을 제외한 하루에 요금을 부과한다는 규칙에서 나온다.

완성 코드에서는 정상 금액을 검사하는 여러 사례가 같은 준비 절차를 사용하므로 CheckFee로 묶는다. 사례 이름과 입력, 기대값은 Main에서 확인할 수 있다. 공통 함수는 계산기를 준비하고 실제 결과를 얻은 뒤 비교한다. 공통화한 뒤에도 개별 사례가 무엇을 확인하는지 드러나야 한다.

검사 하나의 실패가 나머지 검사를 막지 않도록 RunTest가 예외를 받아 실패로 기록한다. 반면 검사 대상인 FeeCalculator는 자신이 처리할 수 없는 입력에 예외를 발생시킨다. 업무 코드의 입력 검증과 검사 실행기의 실패 집계는 서로 다른 책임이다.

경계값과 예외를 검사하는 방법

경계값(boundary value)은 처리 방식이 바뀌는 지점과 그 주변 값이다. 이번 규칙에서는 연체 2일까지 무료이고 3일부터 요금이 생긴다. 또한 연체 7일에 1,500원이 되어 상한액에 도달하고, 8일부터는 계산상 금액이 증가해도 반환 금액은 그대로다. 따라서 무료 구간과 유료 구간의 경계, 상한액 주변을 각각 확인해야 한다.

연체 0일은 정상 입력의 최솟값이다. 연체 일수에 음수는 허용하지 않는다. 유예 일수와 상한액은 0 이상이어야 하고, 하루 요금은 0보다 커야 한다. 상한액이 0인 설정은 모든 정상 입력에 대해 요금을 0으로 제한하는 설정으로 허용한다. 이처럼 0을 허용하는지 여부는 매개변수마다 따로 정한다.

완성 코드가 확인하는 연체 일수와 설정의 경계
입력 또는 설정기대 결과확인하려는 규칙
연체 0일0원정상 입력의 최솟값
연체 2일, 3일0원, 300원유예 종료와 첫 부과
연체 6일, 7일, 8일1,200원, 1,500원, 1,500원상한 직전·도달·초과
Integer.MaxValue일1,500원큰 입력의 계산과 상한 적용
연체 -1일범위 예외음수 입력 거부
유예 -1일, 하루 0원, 상한 -1원각각 범위 예외잘못된 설정 거부
유예 종료와 상한 도달의 앞뒤 값을 확인하면 계산 규칙의 전환점을 검사할 수 있다

금액에는 Decimal을 사용한다. 완성 코드의 숫자 뒤에 붙은 D는 Decimal 형식의 리터럴을 뜻한다. 정수로 일수를 곱한 다음 Decimal로 바꾸는 대신, 부과 일수를 먼저 Decimal로 변환하고 하루 요금과 곱한다. 기본 설정에서 매우 큰 연체 일수도 정수 곱셈의 범위를 넘는 문제 없이 계산할 수 있다.

다만 큰 일수 검사가 모든 Decimal 설정의 계산 가능성을 보장하지는 않는다. 하루 요금을 Decimal의 표현 범위에 가까운 값으로 설정하면 곱셈에서 오버플로가 발생할 수 있다. 이번 사례는 도서관의 정해진 요금과 Integer 범위의 일수를 확인한다. 허용할 요금의 최대 범위까지 업무 요구로 정해진다면 설정 검증과 그에 대한 검사도 추가해야 한다.

잘못된 입력을 확인할 때는 예외가 발생했다는 사실만 검사하지 않는다. 기대한 예외 종류인지, 어떤 매개변수 때문에 발생했는지도 확인한다. 예를 들어 연체 -1일을 전달했을 때 ArgumentOutOfRangeException이 발생하고 ParamName이 overdueDays여야 한다. 전혀 다른 오류가 발생했는데도 검사가 통과하면 입력 검증의 문제를 놓칠 수 있다.

콘솔 검사에서 xUnit 프로젝트로 옮기기

콘솔 검사 함수는 외부 패키지 없이 검사 구조를 익히고 작은 계산 규칙을 확인하기에 적합하다. 프로젝트가 커지면 검사 검색, 개별 실행, 결과 보고를 전담하는 도구가 필요해진다. xUnit은 별도 테스트 프로젝트에서 검사 메서드를 실행하고 결과를 보고하는 데 사용하는 테스트 프레임워크다. 이번 장의 실행 프로그램에는 xUnit을 설치하지 않는다.

옮길 때 먼저 업무 코드의 위치를 정한다. FeeCalculator를 Visual Basic 클래스 라이브러리 프로젝트로 분리하면 콘솔 앱과 테스트 프로젝트가 같은 업무 코드를 참조할 수 있다. 검사 대상으로 삼을 형식과 메서드는 다른 프로젝트에서 접근할 수 있어야 하므로 공개 범위를 점검한다. 콘솔용 집계 함수와 출력 코드는 업무 라이브러리에 넣을 필요가 없다.

테스트 프로젝트에는 대상 .NET 버전과 호환되는 xUnit 패키지, 테스트 실행을 연결하는 구성 요소, 업무 라이브러리에 대한 프로젝트 참조가 필요하다. 템플릿이 제공하는 패키지 조합과 실행 방식은 선택한 xUnit 계열에 맞춰 확인한다. 서로 다른 계열의 예제를 섞어 패키지 이름이나 실행 설정만 옮기지 않는다.

테스트 언어가 업무 코드와 같아야 하는 것은 아니다. Visual Basic용 구성이 없는 템플릿을 사용한다면 C#으로 xUnit 테스트 프로젝트를 만들고 Visual Basic 라이브러리를 참조할 수 있다. 이때도 검사할 것은 Calculate에 입력을 주었을 때의 반환값과 예외다. 언어가 달라져도 준비·실행·확인이라는 구조와 경계값 선정 이유는 그대로 유지된다.

xUnit에서는 Fact 특성으로 개별 검사 메서드를 표시하고, 입력과 기대값을 묶어 반복 확인할 때는 Theory와 데이터 특성을 사용할 수 있다. 이번 Main의 CheckFee 호출들은 데이터 기반 검사로 옮기기 좋은 사례다. ExpectEqual과 ExpectArgumentOutOfRange의 역할은 프레임워크의 확인 기능으로 대체하고, passed와 failed의 집계는 실행 도구에 맡긴다.

테스트 프로젝트를 구성한 뒤에는 선택한 실행 구성에 따라 검사를 실행한다. dotnet test를 사용하는 구성에서는 명령의 종료 상태와 결과 보고를 자동화 과정에서 확인한다. 콘솔 앱에 여러 검사 함수를 추가했다는 이유만으로 그 함수들이 dotnet test에서 검색되는 것은 아니다. 별도의 테스트 프로젝트와 프레임워크에 맞는 검사 선언이 필요하다. 구성에 관한 사실은 xUnit 공식 안내와 .NET 테스트 안내에서 확인할 수 있다.

완성 코드

Visual Basic 콘솔 프로젝트의 Program.vb를 다음 코드로 교체한다. 기본 라이브러리만 사용하며, 정상 실행에서는 11개 검사를 모두 수행한 뒤 종료한다. 검사 실패가 있으면 종료 코드를 1로 설정한다.

Option Strict On
Option Explicit On
Option Infer On

Imports System

Module Program
    Private passed As Integer
    Private failed As Integer

    Sub Main()
        passed = 0
        failed = 0

        RunTest("연체 0일", Sub() CheckFee(0, 0D))
        RunTest("유예 마지막 날", Sub() CheckFee(2, 0D))
        RunTest("첫 부과일", Sub() CheckFee(3, 300D))
        RunTest("상한 직전", Sub() CheckFee(6, 1200D))
        RunTest("상한 도달", Sub() CheckFee(7, 1500D))
        RunTest("상한 초과", Sub() CheckFee(8, 1500D))
        RunTest("큰 연체 일수",
                Sub() CheckFee(Integer.MaxValue, 1500D))
        RunTest("음수 연체 일수", AddressOf CheckNegativeDays)
        RunTest("음수 유예 일수", AddressOf CheckNegativeFreeDays)
        RunTest("0원 하루 요금", AddressOf CheckZeroDailyFee)
        RunTest("음수 상한액", AddressOf CheckNegativeMaximumFee)

        Console.WriteLine(
            $"결과: 성공 {passed}, 실패 {failed}, 합계 {passed + failed}")
        Environment.ExitCode = If(failed = 0, 0, 1)
    End Sub

    Private Sub RunTest(name As String, test As Action)
        Try
            test()
            passed += 1
            Console.WriteLine($"[성공] {name}")
        Catch ex As Exception
            failed += 1
            Console.WriteLine($"[실패] {name}: {ex.Message}")
        End Try
    End Sub

    Private Sub CheckFee(days As Integer, expected As Decimal)
        ' 준비
        Dim calculator As New FeeCalculator(2, 300D, 1500D)

        ' 실행
        Dim actual As Decimal = calculator.Calculate(days)

        ' 확인
        ExpectEqual(expected, actual)
    End Sub

    Private Sub CheckNegativeDays()
        Dim calculator As New FeeCalculator(2, 300D, 1500D)
        Dim action As Action =
            Sub()
                Dim ignored As Decimal = calculator.Calculate(-1)
            End Sub

        ExpectArgumentOutOfRange("overdueDays", action)
    End Sub

    Private Sub CheckNegativeFreeDays()
        Dim action As Action =
            Sub()
                Dim ignored As New FeeCalculator(-1, 300D, 1500D)
            End Sub

        ExpectArgumentOutOfRange("freeDays", action)
    End Sub

    Private Sub CheckZeroDailyFee()
        Dim action As Action =
            Sub()
                Dim ignored As New FeeCalculator(2, 0D, 1500D)
            End Sub

        ExpectArgumentOutOfRange("dailyFee", action)
    End Sub

    Private Sub CheckNegativeMaximumFee()
        Dim action As Action =
            Sub()
                Dim ignored As New FeeCalculator(2, 300D, -1D)
            End Sub

        ExpectArgumentOutOfRange("maximumFee", action)
    End Sub

    Private Sub ExpectEqual(expected As Decimal, actual As Decimal)
        If expected <> actual Then
            Throw New InvalidOperationException(
                $"기대값 {expected}, 실제값 {actual}")
        End If
    End Sub

    Private Sub ExpectArgumentOutOfRange(
        expectedParameter As String,
        action As Action)

        Try
            action()
        Catch ex As ArgumentOutOfRangeException
            If ex.ParamName <> expectedParameter Then
                Throw New InvalidOperationException(
                    $"기대 매개변수 {expectedParameter}, " &
                    $"실제 매개변수 {ex.ParamName}")
            End If
            Return
        End Try

        Throw New InvalidOperationException(
            "ArgumentOutOfRangeException이 발생하지 않았다.")
    End Sub
End Module

Public Class FeeCalculator
    Private ReadOnly freeDays As Integer
    Private ReadOnly dailyFee As Decimal
    Private ReadOnly maximumFee As Decimal

    Public Sub New(
        freeDays As Integer,
        dailyFee As Decimal,
        maximumFee As Decimal)

        If freeDays < 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(freeDays))
        End If
        If dailyFee <= 0D Then
            Throw New ArgumentOutOfRangeException(NameOf(dailyFee))
        End If
        If maximumFee < 0D Then
            Throw New ArgumentOutOfRangeException(NameOf(maximumFee))
        End If

        Me.freeDays = freeDays
        Me.dailyFee = dailyFee
        Me.maximumFee = maximumFee
    End Sub

    Public Function Calculate(overdueDays As Integer) As Decimal
        If overdueDays < 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(overdueDays))
        End If

        If overdueDays <= freeDays Then
            Return 0D
        End If

        Dim chargeableDays As Integer = overdueDays - freeDays
        Dim amount As Decimal = CDec(chargeableDays) * dailyFee
        Return Math.Min(amount, maximumFee)
    End Function
End Class

줄별 해설

첫 세 줄은 형식 변환과 변수 선언의 기준을 명시한다. Option Strict On은 의도하지 않은 축소 변환과 늦은 바인딩을 제한한다. Option Explicit On은 선언하지 않은 변수의 사용을 막는다. Option Infer On은 초기화 식으로 지역 변수의 형식을 추론할 수 있게 한다. 이 프로그램은 주요 값의 형식을 직접 적어 검사 대상의 입력과 결과가 잘 보이도록 했다.

passed와 failed는 검사 실행기의 집계 상태다. Main의 시작에서 두 값을 0으로 초기화한 뒤 RunTest를 순서대로 호출한다. 정상 결과를 확인하는 일곱 호출에는 연체 일수와 기대 금액이 들어 있다. 각 람다는 호출 시 실행할 작업을 Action으로 전달한다. 예외 검사 네 개는 별도 메서드의 주소를 AddressOf로 전달한다.

RunTest 안의 test()가 실제 검사 사례를 실행하는 줄이다. 검사 함수가 끝까지 돌아오면 성공 수를 늘린다. 검사 함수가 예외를 던지면 실패 수를 늘리고 사례 이름과 메시지를 출력한다. 여기서 Exception을 넓게 잡는 목적은 어떤 실패가 나더라도 나머지 사례를 계속 실행하기 위해서다. 업무 코드에서 모든 예외를 숨기는 용도로 이 구조를 옮겨 쓰지는 않는다.

CheckFee의 첫 줄은 준비에 해당한다. 각 호출마다 새 계산기를 만들기 때문에 한 사례의 객체 상태가 다른 사례에 이어지지 않는다. Calculate 호출은 실행 단계이고, ExpectEqual 호출은 확인 단계다. expected는 Main에 적힌 규칙상의 금액이며 actual은 계산기가 실제로 반환한 금액이다.

예외 검사에서 action 변수를 만드는 것만으로는 잘못된 입력이 실행되지 않는다. 람다의 본문은 나중에 action()이 호출될 때 실행된다. 따라서 예외를 확인할 함수가 해당 호출을 자신의 Try 블록 안에서 실행할 수 있다. ignored는 반환값이나 생성한 객체를 받는 지역 변수다. 이 사례에서는 값 자체보다 호출 과정에서 발생하는 예외가 확인 대상이다.

ExpectEqual은 두 Decimal 값이 다를 때 InvalidOperationException을 발생시킨다. 확인 실패를 예외로 표현했으므로 RunTest는 반환값을 따로 해석할 필요 없이 성공과 실패를 집계할 수 있다. 기대값과 실제값을 함께 남기는 메시지는 규칙이 잘못되었는지, 검사에 적은 숫자가 잘못되었는지 조사하는 출발점이 된다.

ExpectArgumentOutOfRange는 ArgumentOutOfRangeException만 받아 처리한다. ParamName이 기대와 다르면 확인 실패를 발생시킨다. 종류와 매개변수 이름이 모두 맞으면 Return으로 함수를 끝낸다. 다른 종류의 예외는 이 Catch에 걸리지 않고 RunTest까지 전달되어 실패로 기록된다. 예외가 전혀 없으면 Try 블록 뒤의 Throw가 실행된다.

FeeCalculator의 생성자는 설정을 검증한 뒤 ReadOnly 필드에 저장한다. Me.freeDays처럼 Me를 붙이면 같은 이름의 생성자 매개변수와 필드를 구별할 수 있다. Calculate는 먼저 음수 입력을 거부하고, 유예 기간 안이면 0을 반환한다. 나머지 경우에는 유예 일수를 뺀 뒤 금액을 계산하고 Math.Min으로 상한액을 적용한다.

유예 기간 확인을 통과한 뒤에는 overdueDays가 freeDays보다 크다. 두 값은 음수가 아니므로 이 지점의 일수 뺄셈은 정상적인 양수 범위에 있다. CDec는 곱셈 전에 부과 일수를 Decimal로 바꾼다. 마지막 Environment.ExitCode 설정은 검사 결과를 프로그램 밖에서도 판별할 수 있게 한다. 실패 줄을 사람이 읽는 것과 실행 성공 여부를 다른 도구가 확인하는 것을 함께 지원한다.

실행 결과

빈 작업 디렉터리에서 .NET 10 SDK로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 다음 실행한다. 프로젝트 생성 과정의 안내 문구는 SDK 구성에 따라 달라질 수 있으므로 아래에는 실행 프로그램의 출력만 제시한다.

dotnet new console -lang VB -f net10.0
dotnet run
[성공] 연체 0일
[성공] 유예 마지막 날
[성공] 첫 부과일
[성공] 상한 직전
[성공] 상한 도달
[성공] 상한 초과
[성공] 큰 연체 일수
[성공] 음수 연체 일수
[성공] 음수 유예 일수
[성공] 0원 하루 요금
[성공] 음수 상한액
결과: 성공 11, 실패 0, 합계 11

검사는 등록한 순서대로 실행된다. 현재 날짜, 무작위 값, 실행 시간을 사용하지 않으므로 동일한 코드에서는 같은 성공 출력이 나온다. 모든 검사가 통과하면 프로그램의 종료 코드는 0이다. 이것은 작성한 11개 사례가 통과했다는 뜻이며, 아직 작성하지 않은 모든 조건까지 확인했다는 뜻은 아니다.

실무에서 자주 틀리는 것

기대값을 검사 대상에게 다시 묻는다

기대값과 실제값을 같은 메서드로 구하면 구현이 잘못되어도 둘이 같을 수 있다. 검사가 확인해야 할 기준은 업무 규칙에서 가져와야 한다.

틀린 코드는 다음과 같다.

Dim calculator As New FeeCalculator(2, 300D, 1500D)
Dim expected As Decimal = calculator.Calculate(3)
Dim actual As Decimal = calculator.Calculate(3)
ExpectEqual(expected, actual)

고친 코드는 첫 부과일의 금액을 직접 적는다.

Dim calculator As New FeeCalculator(2, 300D, 1500D)
Dim actual As Decimal = calculator.Calculate(3)
ExpectEqual(300D, actual)

복잡한 규칙에서도 기대값을 산출한 근거가 검사 대상 구현과 독립적인지 살핀다. 숫자를 직접 적는 것만으로 충분한 것은 아니며, 그 숫자가 어떤 요구에서 나왔는지 사례 이름이나 설명으로 드러내야 한다.

예외가 없는데도 검사가 끝난다

예외가 발생한 경우에만 확인하고 정상 반환한 경우를 빠뜨리면 입력 검증이 사라져도 검사가 통과한다. 또한 넓은 Catch는 관계없는 오류까지 기대한 결과로 받아들일 수 있다.

틀린 코드는 다음과 같다.

Dim calculator As New FeeCalculator(2, 300D, 1500D)
Try
    Dim ignored As Decimal = calculator.Calculate(-1)
Catch ex As Exception
    Return
End Try

고친 코드는 예외가 없을 때 실패하는 공통 함수를 사용한다.

Dim calculator As New FeeCalculator(2, 300D, 1500D)
Dim action As Action =
    Sub()
        Dim ignored As Decimal = calculator.Calculate(-1)
    End Sub

ExpectArgumentOutOfRange("overdueDays", action)

이 함수는 예상한 종류와 매개변수를 확인한다. 예외가 없거나 다른 예외가 발생하면 RunTest가 실패로 집계한다.

경계의 한쪽만 확인한다

무료 구간의 마지막 날만 검사하면 첫 유료일의 계산 오류를 놓칠 수 있다. 조건문의 등호 하나가 잘못되어도 특정 입력에서는 정상 결과가 나올 수 있다.

틀린 검사 구성은 다음과 같다.

RunTest("유예 마지막 날", Sub() CheckFee(2, 0D))

고친 구성은 처리 방식이 바뀌는 다음 날도 확인한다.

RunTest("유예 마지막 날", Sub() CheckFee(2, 0D))
RunTest("첫 부과일", Sub() CheckFee(3, 300D))

상한액도 같은 방식으로 접근한다. 상한 도달 사례만 두지 않고 직전과 초과 사례를 함께 두면 금액 증가와 제한 적용을 나누어 확인할 수 있다.

실패를 출력하고 정상 종료 상태를 남긴다

콘솔에 실패가 보이더라도 종료 코드가 0이면 프로그램을 실행한 자동화 도구가 성공으로 판단할 수 있다. 출력과 종료 상태가 같은 결과를 나타내야 한다.

틀린 코드는 다음과 같다.

Console.WriteLine($"결과: 성공 {passed}, 실패 {failed}")
Environment.ExitCode = 0

고친 코드는 실패 수를 종료 상태에 반영한다.

Console.WriteLine($"결과: 성공 {passed}, 실패 {failed}")
Environment.ExitCode = If(failed = 0, 0, 1)

검사 실행기의 이 기능은 배포 환경에 대한 판단까지 대신하지 않는다. 실행한 검사들이 실패했는지를 명확히 전달하는 역할이다.

한눈에 보기

검사 가능한 코드와 검사 사례를 작성할 때의 기준
항목이번 코드의 선택확인할 질문
입력 통제연체 일수와 규칙을 명시한다.같은 조건을 다시 만들 수 있는가
결과 관찰금액을 Decimal로 반환한다.출력 문구 없이 결과를 비교할 수 있는가
검사 구조준비·실행·확인을 구분한다.무엇을 실행하고 확인하는지 보이는가
경계 검사유예와 상한의 앞뒤를 확인한다.처리 방식이 바뀌는 입력을 포함했는가
예외 검사종류와 매개변수 이름을 확인한다.예외가 없어도 실패하는가
실행 보고성공·실패 수와 종료 코드를 남긴다.실패를 프로그램 밖에서 구별할 수 있는가
xUnit 이전업무 라이브러리를 별도로 참조한다.업무 코드와 실행 도구의 역할이 나뉘는가

검사 함수는 코드를 실행해 보는 절차를 반복 가능한 기준으로 바꾼다. 새로운 규칙을 추가할 때는 정상 사례뿐 아니라 처리 방식이 달라지는 지점과 거부해야 할 입력을 함께 적는다. 다음 장에서 저장 기능을 다룰 때도 금액 계산은 이 계산기에 남겨 두면 저장 방식의 변화와 계산 규칙의 변화를 따로 확인할 수 있다.

연습 문제

  1. 상한액이 0인 설정을 검사하는 CheckZeroMaximumFee를 작성한다. 유예 기간을 지난 연체 3일에도 0원을 반환하는지 확인하고 Main에 등록한다.
  2. 유예 일수가 0인 설정의 경계를 검사한다. 하루 요금은 300원, 상한액은 1,500원으로 두고 연체 0일과 1일의 기대값을 각각 확인한다.
  3. CheckFee(3, 0D)를 실행하는 사례를 기존 완성 코드에 하나 추가한다. 어느 단계에서 실패하는지, 최종 성공·실패 수와 종료 코드는 무엇인지 설명한다.
  4. ExpectArgumentOutOfRange에서 ParamName 비교를 없앴다고 가정한다. 어떤 결함을 놓칠 수 있는지 설명하고, 매개변수 이름 검사가 구현 세부 사항을 과도하게 검사하는 경우도 함께 판단한다.

정답과 해설

1. 상한액이 0인 설정

생성자는 상한액 0을 허용한다. 연체 3일의 계산상 금액은 300원이지만 상한액을 적용하면 0원이 된다. 다음 메서드를 Module Program 안에 추가한다.

Private Sub CheckZeroMaximumFee()
    Dim calculator As New FeeCalculator(2, 300D, 0D)
    Dim actual As Decimal = calculator.Calculate(3)
    ExpectEqual(0D, actual)
End Sub

Main의 집계 출력 전에 다음 호출을 추가한다.

RunTest("0원 상한액", AddressOf CheckZeroMaximumFee)

다른 코드를 그대로 두면 성공 12개, 실패 0개가 된다. 이 사례는 무료 구간이라서 0원이 나오는 경우와 상한액 때문에 0원이 나오는 경우를 구별한다.

2. 유예 일수가 0인 설정

연체 0일은 0원이고 연체 1일부터 300원이 부과된다. 기본 설정을 만드는 CheckFee 대신 해당 설정의 계산기를 직접 준비한다.

Private Sub CheckNoFreeDays()
    Dim calculator As New FeeCalculator(0, 300D, 1500D)

    Dim zeroDayFee As Decimal = calculator.Calculate(0)
    Dim firstDayFee As Decimal = calculator.Calculate(1)

    ExpectEqual(0D, zeroDayFee)
    ExpectEqual(300D, firstDayFee)
End Sub

Main에서는 다음과 같이 등록한다.

RunTest("유예 없는 경계", AddressOf CheckNoFreeDays)

이 메서드는 연결된 두 경계를 한 사례로 확인한다. 첫 확인에서 실패하면 다음 확인은 실행되지 않는다. 두 결과를 독립적으로 보고해야 한다면 각 입력을 별도 사례로 나누는 편이 적합하다.

3. 의도적으로 틀린 기대값

준비와 실행은 정상으로 끝나고 확인 단계의 ExpectEqual에서 실패한다. 실제값은 300원인데 기대값을 0원으로 적었으므로 InvalidOperationException이 발생한다. RunTest가 이를 받아 실패 수를 늘리며 나머지 검사도 계속 실행한다.

RunTest("틀린 기대값", Sub() CheckFee(3, 0D))

기존 11개 검사에 이 사례만 추가하면 다음 실패 줄이 나타나고 최종 성공은 11개, 실패는 1개, 합계는 12개다. 종료 코드는 1이다.

[실패] 틀린 기대값: 기대값 0, 실제값 300
결과: 성공 11, 실패 1, 합계 12

이 변경은 검사 실행기가 실제로 실패를 보고하는지 확인하는 실험이다. 확인을 마친 뒤에는 잘못된 기대값을 그대로 두지 않는다.

4. 매개변수 이름 검사의 범위

Calculate가 음수 연체 일수를 거부하면서 실수로 NameOf(freeDays)를 예외에 넣었다면 예외 종류만 확인하는 검사는 통과한다. ParamName을 함께 확인하면 호출자에게 잘못된 입력 위치를 알리는 결함을 찾을 수 있다. 이번 코드는 잘못된 매개변수를 식별하는 동작을 검사 기준으로 삼는다.

반면 필드 저장 방식이나 지역 변수 이름처럼 호출자가 관찰할 필요 없는 내부 구조까지 검사하면 구현을 정리할 때 검사가 불필요하게 깨질 수 있다. 공개 매개변수 이름도 변경이 허용되는 요구라면 검사 기준을 함께 조정해야 한다. 예외 메시지 전체 문자열은 언어와 실행 환경에 따라 달라질 수 있으므로 이번 검사에서는 비교하지 않는다.

댓글 0

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

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