[테스트 주도 개발 시작하기] CHAPTER 9 - 테스트 범위와 종류

myeonji·2023년 2월 22일
0
  • 테스트 범위
  • 통합 테스트 예

테스트 범위

하나의 기능이 올바르게 작동하려면 컨트롤러, 서비스, 모델과 같은 자바 코드와 프레임워크 설정에 문제가 없어야 할 뿐 아니라 브라우저에서 실행되는 자바스크립트 코드, HTML과 CSS 등이 정상 동작해야 한다. 또한, DB의 테이블도 올바르게 생성되어 있어야 한다.

개발자가 코드를 조금씩 수정할 때마다 브라우저부터 DB까지의 전 범위를 테스트해야 하는 것은 아니며 출시 전에 DB와 연동할 때 사용한 쿼리만 확인해서도 안 된다. 테스트의 범위는 테스트의 목적과 수행하는 사람에 따라 달라진다. 테스트 범위에 따른 종류는 세 가지로 나눠볼 수 있다.

  • 단위 테스트
  • 통합 테스트
  • 기능 테스트

테스트 관련 용어는 문맥이나 사용자에 따라 의미가 다를 때도 있다. 예를 들어 개발 완료 후에 진행하는 최종 테스트를 '통합 테스트'라고 부르기도 한다. 고객의 입장에서 요구한 기능을 올바르게 구현했는지 수행하는 테스트를 '인수 테스트'라고 부르는데 요건을 완료했는지 정의하기 위해 작성한 테스트를 '인수 테스트'라고 부르기도 한다.

기능 테스트와 E2E 테스트

기능 테스트(Functional Testing)는 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인한다. 이 테스트를 수행하려면 시스템을 구동하고 사용하는데 필요한 모든 구성 요소가 필요하다. 예를 들어 회원 가입 기능이 올바르게 작동하는지 확인하려면 웹 서버, 데이터베이스, 웹 브라우저가 필요하다. 회원 가입 과정에서 문자 발송 기능이 필요하다면 외부의 문자 발송 서비스도 필요하다.

기능 테스트는 사용자가 직접 사용하는 웹 브라우저나 모바일 앱부터 시작해서 데이터베이스나 외부 서비스에 이르기까지 모든 구성 요소를 하나로 엮어서 진행한다. 이는 끝(브라우저)에서 끝(데이터베이스)까지 모든 구성 요소를 논리적으로 완전한 하나의 기능으로 다룬다. 기능 테스트는 끝에서 끝까지 올바른지 검사하기 때문에 E2E(End to end) 테스트로도 볼 수 있다.

QA 조직에서 수행하는 테스트가 주로 기능 테스트이다.

기능 테스트를 자동화했다면 DB에 보관된 데이터를 삭제하거나 수정하는 것과 같이 DB 데이터를 테스트 코드에서 조작할 수 있지만, 이는 반복적인 기능 테스트를 위한 것이지 기능 테스트 결과를 검증하기 위한 용도는 아니다. 물론 기능 테스트의 결과를 검증하기 위해 DB 데이터를 직접 조회할 수는 있지만, 이것은 마치 테스트가 브라우저로 회원 가입 기능을 실행한 뒤에 DB에 SELECT 쿼리를 보내서 데이터가 잘 들어갔는지 확인하는 것과 같다. 보통의 기능 테스트라면 회원 가입을 실행한 후에 데이터가 올바르게 들어갔는지 확인하기 위해 개인 정보 조회 화면을 실행하거나 관리 도구의 회원 정보 조회 기능을 실행할 것이다. 부득이한 경우를 제외하면 기능 테스트는 사용자와 동일한 방식으로 기능을 검증해야 한다.

통합 테스트

통합 테스트(Integration Testing)는 시스템의 각 구성 요소가 올바르게 연동되는지 확인한다. 기능 테스트가 사용자 입장에서 테스트하는 데 반해 통합 테스트는 소프트웨어의 코드를 직접 테스트한다. 모바일 앱을 예로 들면 기능 테스트는 앱을 통해 가입 기능을 테스트한다면 통합 테스트는 서버의 회원 가입 코드를 직접 테스트하는 식이다.

일반적인 웹 어플리케이션은 프레임워크, 라이브러리, 데이터베이스, 구현한 코드가 주요 통합 테스트 대상이다. 스프링 프레임워크, JPA, MariaDB를 이용해서 회원 가입 관련된 서비스 클래스, DAO 인터페이스, SQL 쿼리를 구현했다면 이들을 통합한 회원 가입 서비스 클래스에 대한 테스트가 통합 테스트 예가 될 수 있다. 회원 가입 코드에 대한 통합 테스트를 수행하면 스프링 프레임워크나 마이바티스 설정이 올바른지, SQL 쿼리가 맞는지, DB 트랜잭션이 잘 동작하는지 등을 검증할 수 있다.

단위 테스트

단위 테스트(Unit Testing)는 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인한다. 지금까지 이 책에서 살펴본 테스트가 주로 단위 테스트 코드이다. 단위 테스트는 한 클래스나 한 메서드와 같은 작은 범위를 테스트한다. 일부 의존 대상은 스텁이나 모의 객체 등을 이용해서 대역으로 대체한다.

테스트 범위 간 차이

  • 통합 테스트를 실행하려면 DB나 캐시 서버와 같은 연동 대상을 구성해야 한다. 기능 테스트를 실행하려면 웹 서버를 구동하거나 모바일 앱을 폰에 설치해야 할 수도 있다. 또한, 통합 테스트나 기능 테스트는 테스트 상황을 만들어내기 위해 많은 노력이 필요하다. 반면에 단위 테스트는 테스트 코드를 빼면 따로 준비할 것이 없다.
  • 통합 테스트는 DB 연결, 소켓 통신, 스프링 컨테이너 초기화와 같이 테스트 실행 속도를 느리게 만드는 요인이 많다. 기능 테스트는 추가로 브라우저나 앱을 구동하고 화면의 흐름에 따라 알맞은 상호 작용을 해야 한다. 반면에 단위 테스트는 서버를 구동하거나 DB를 준비할 필요가 없다. 테스트 대상이 의존하는 기능을 대역으로 처리하면 되므로 테스트 실행 속도가 빠르다.
  • 통합 테스트나 기능 테스트로는 상황을 준비하거나 결과 확인이 어렵거나 불가능할 때가 있다. 외부 시스템과 연동해야 하는 기능이 특히 그렇다. 이런 경우에는 단위 테스트와 대역을 조합해서 상황을 만들고 결과를 확인해야 한다.

TDD를 하는지 여부에 상관없이 테스트 코드를 작성하는 개발자는 단위 테스트와 통합 테스트를 섞어서 작성한다. 통합 테스트를 실행하려면 준비할 것이 많고 단위 테스트에 비해 실행 시간도 길지만, 그래도 통합 테스트는 필요하다. 아무리 단위 테스트를 많이 만든다고 해도 결국은 각 구성 요소가 올바르게 연동되는 것을 확인해야 하는데 이를 자동화하기 좋은 수단이 통합 테스트 코드이기 때문이다.

테스트 범위에 따른 테스트 코드 개수와 시간

기능 테스트, 통합 테스트, 단위 테스트 등 전 범위에 대해 테스트를 자동화하는 시도가 증가하고 있다. 테스트를 자동화하는 만큼 고품질의 소프트웨어를 더 빠르게 출시할 수 있기 때문이다. 테스트를 자동화한다는 것은 결국 코드로 작성한 테스트를 실행한다는 것을 의미한다.

기능 테스트를 수행하려면 브라우저나 모바일 앱과 같은 클라이언트로부터 DB까지 모든 환경이 갖춰져야 하기에 자동화하거나 다양한 상황별로 테스트하기 가장 어렵다. 이런 이유로 정기적으로 수행하는 기능 테스트는 정상적인 경우와 몇 가지 특수한 상황만 테스트 범위로 잡는다. 기능 테스트를 수행하기 위한 알맞은 도구가 없으면 기능 테스트 코드를 만들기 힘들 수도 있다.

통합 테스트는 기능 테스트에 비해 제약이 덜하다. 또한, 시스템의 내부 구성 요소에 대한 테스트도 가능하다. 통합 테스트는 기능 테스트에 비해 상대적으로 실행 시간이 짧고 상황을 보다 유연하게 구성할 수 있기 때문에 보통 기능 테스트보다 통합 테스트를 더 많이 작성한다.

(느림) E2E 테스트 <-> 통합 테스트 <-> 단위 테스트 (빠름)

단위 테스트는 통합 테스트로도 만들기 힘든 상황을 쉽게 구성할 수 있다. 더 작은 단위를 대상으로 테스트 코드를 만들고 더 다양한 상황을 다루기 때문에 통합 테스트보다 단위 테스트 코드를 더 많이 작성하게 된다.

테스트 속도는 통합 테스트보다 단위 테스트가 빠르기 때문에 가능하면 단위 테스트에서 다양한 상황을 다루고, 통합 테스트나 기능 테스트는 주요 상황에 초점을 맞춰야 한다. 테스트 실행 속도가 느려지면 테스트를 작성하지 않거나 테스트 실행을 생략하는 상황이 벌어진다. 이는 결국 소프트웨어의 품질 저하로 이어질 수 있기 때문에 가능하면 빠른 시간 내에 테스트를 실행할 수 있도록 해야 한다.

외부 연동이 필요한 테스트 예

소프트웨어는 다양한 외부 연동이 필요하다. 외부 연동 대상은 쉽게 제어할 수 없기 때문에 연동해야 할 대상이 늘어날수록 통합 테스트도 힘들어진다.

모든 외부 연동 대상을 통합 테스트에서 다룰 수 없지만, 일부 외부 대상은 어느 정도 수준에서 제어가 가능하다.

스프링 부트와 DB 통합 테스트

스프링에 빈 객체로 등록된 타입은 다음과 같다.

  • UserRegister
  • SimpleWeakPasswordChecker
  • UserRepository (스프링 데이터 JPA를 이용해서 등록)
  • VirtualEmailNotifier
@SpringBootTest
public class UserRegisterIntTest {
    
    @Autowired
    private UserRegister register;
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void 동일ID가_이미_존재하면_익셉션() {
        // 상황 : INSERT 쿼리 실행
        jdbcTemplate.update(
                "insert into user values (?,?,?) " +
                "on duplicate key update password = ?, email = ?", 
                "cbk", "pw", "cbk@cbk.com", "pw", "cbk@cbk.com");
        )
        
        // 실행, 결과 확인
        assertThrows(DupIdException.class, () -> register.register("cbk", "strongpw", "email@email.com")
        );
    }

    @Test
    void 존재하지_않으면_저장함() {
        // 상황 : DELETE 쿼리 실행
        jdbcTemplate.update("delete from user where id = ?", "cbk");
        // 실행
        register.register("cbk", "strongpw", "email@email.com");
        // 결과 확인 : SELECT 쿼리 실행
        SqlRowSet rs = jdbcTemplate.queryForRowSet(
                "select * from user where id = ?", "cbk");
        rs.next();
        assertEquals("email@email.com", rs.getString("email"));
        )
    }
}

통합 테스트는 실제로 DB를 사용한다. 동일한 테스트를 여러 번 실행해도 결과가 같게 나와야하므로 테스트 코드에서 DB 데이터를 알맞게 제어해야 한다. 데이터가 존재하는 상황을 만들기 위해 DB에 데이터를 추가해야 하고 존재하지 않는 상황을 만들기 위해 DB에서 데이터를 삭제해야 한다.

먼저 동일 ID가 존재할 때 가입에 실패하는지 검증하는 테스트 메서드를 보면, 이 메서드는 동일한 ID가 존재한다는 상황을 만들기 위해 해당 테이블에 데이터를 삽입한다. 실제로 ID가 "cbk"인 데이터가 이미 존재할 수도 있으므로 ON DUPLICATE KEY를 사용해서 INSERT 쿼리에서 오류가 발생하지 않도록 했다.

동일 ID가 존재하지 않을 때 회원 정보를 올바르게 저장하는지 검증하는 테스트 메서드를 보면, 테스트에서 사용할 ID를 가진 데이터가 실제로 존재할 수도 있기 때문에 DELETE 쿼리를 실행해서 동일 ID를 가진 회원 데이터가 존재하지 않는 상황을 만든다.

단위 테스트와 비교해보자.
단위 테스트는 상황을 만들기 위해 대역을 사용한다.

public class UserRegisterTest {
    private UserRegister userRegister;
    private MemoryUserRepository fakeRepository = new MemoryUserRepository();

    @DisplayName("이미 같은 ID가 존재하면 가입 실패")
    @Test
    void dupIdExists() {
        // 이미 같은 ID 존재하는 상황 만들기
        fakeRepository.save(new User("id", "pw1", "email@email.com"));

        assertThrows(DupIdException.class, () -> {
            userRegister.register("id", "pw2", "email");
        });
    }

메모리를 이용한 가짜 구현을 사용해서 동일 ID가 존재하는 상황을 만들었다.

통합 테스트와 단위 테스트는 실행 시간에도 차이가 있다. 스프링 부트를 이용한 통합 테스트는 테스트 메서드를 실행하기 전에 스프링 컨테이너를 생성하는 과정이 필요하다. 반면에 단위 테스트는 이런 과정이 없으므로 테스트를 실행하는 시간이 매우 짧다.

WireMock을 이용한 REST 클라이언트 테스트

통합 테스트하기 어려운 대상이 외부 서버이다.
아래는 외부 카드사 API를 이용해서 카드번호가 유효한지 확인하는 코드이다.

public class CardNumberValidator {
    
    private String server;
    
    public CardNumberValidator(String server) {
        this.server = server;
    }
    
    public CardValidity validate(String cardNumber) {
        HttpClient httpClient = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(server + "/card"))
                .header("Content-Type", "text/plain")
                .POST(HttpRequest.BodyPublishers.ofString(cardNumber))
                .timeout(Duration.ofSeconds(3))
                .build();

        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
            switch (response.body()) {
                case "ok":
                    return CardValidity.VALID;
                case "bad":
                    return CardValidity.INVALID;
                case "expired":
                    return CardValidity.EXPIRED;
                case "theft":
                    return CardValidity.THEFT;
                default:
                    return CardValidity.UNKNOWN;
            }
        } catch (HttpTimeoutException e) {
            return CardValidity.TIMEOUT;
        } catch (IOException | InterruptedException exception) {
            return CardValidity.ERROR;
        }
    }
}

7장 코드와 비교해서 세 군데 코드를 변경했다.
우선 서버 주소를 생성자를 이용해서 받도록 했다. 그리고 타임아웃을 3초로 지정하고 타임아웃 관련 익셉션을 별도로 처리하도록 했다.

CardNumberValidator 자체를 테스트하려면 정해진 규칙에 맞게 통신할 수 있는 서버가 필요하다. 테스트하려면 외부의 카드 정보 제공 API와 통신해야 하는데 원하는 상황을 쉽게 만들 수 없다. 예를 들어 외부 업체에 테스트할 때마다 타임아웃이 발생하게 처리 시간을 늘려 달라고 부탁할 수는 없다.

WireMock을 사용하면 서버 API를 스텁으로 대체할 수 있다. WireMock을 사용하면 올바른 응답이나 타임아웃과 같은 상황에 대해 CardNumberValidator를 테스트할 수 있다.

public class CardNumberValidatorTest {

    private WireMockServer wireMockServer;

    @BeforeEach
    void setUp() {
        wireMockServer = new WireMockServer(options().port(8089));
        wireMockServer.start();
    }

    @AfterEach
    void tearDown() {
        wireMockServer.stop();
    }

    @Test
    void valid() {
        wireMockServer.stubFor(post(urlEqualTo("/card"))
                .withRequestBody(equalTo("1234567890"))
                .willReturn(aResponse()
                        .withHeader("Content-Type", "text/plain")
                        .withBody("ok")));

        CardNumberValidator validator = new CardNumberValidator("http://localhost:8089");
        CardValidity validity = validator.validate("1234567890");
        assertEquals(CardValidity.VALID, validity);
    }

    @Test
    void timeout() {
        wireMockServer.stubFor(post(urlEqualTo("/card"))
                .willReturn(aResponse()
                        .withFixedDelay(5000)));

        CardNumberValidator validator = new CardNumberValidator("http://localhost:8089");
        CardValidity validity = validator.validate("1234567890");
        assertEquals(CardValidity.TIMEOUT, validity);
    }
}

WireMockServer는 HTTP 서버를 흉내 낸다.

  • 테스트 실행 전에 WireMockServer를 시작한다. 실제 HTTP 서버가 뜬다.
  • 테스트에서 WireMockServer의 동작을 기술한다.
  • HTTP 연동을 수행하는 테스트를 실행한다.
  • 테스트 실행 후에 WireMockServer를 중지한다.

@BeforEach 메서드를 이용해서 각 테스트를 실행하기 전에 WireMockServer를 생성하고 시작한다. @AfterEach 메서드를 이용해서 테스트 실행 후에 WireMockServer를 중지한다.

  • 요청이 다음과 같으면

    • URL이 "/card"
    • POST 요청
    • 요청 몸체가 "1234567890"
  • 아래와 같이 응답

    • Content-Type이 text/plain이고
    • 응답 몸체가 "ok"

CardNumberValidator를 생성할 때 서버로 "http://localhost:8089"를 지정해서 WireMockServer가 제공하는 HTTP 서버에 연결한다.

WireMockServer가 제공하는 HTTP 서버에 "/card" 경로를 POST 방식으로 요청하고 이때 요청 몸체로 "1234567890"을 전송한다. 따라서 WireMockServer는 "ok"를 응답으로 전송하고 결과적으로 유효한 카드번호에 대한 테스트를 수행할 수 있게 된다.

WireMock은 JSON/XML 응답, HTTPS 지원, 단독 실행 등 다양한 기능을 제공하므로 외부 연동 코드를 테스트할 때 유용하게 사용할 수 있다.

스프링 부트의 내장 서버를 이용한 API 기능 테스트

모바일 앱에서 회원 가입을 위해 사용하는 회원 가입 API가 올바르게 JSON을 응답하는지 검증해야 한다고 가정한다.

@SpringBootTest(
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
public class UserApiE2ETest {
    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void weakPwResponse() {
        String reqBody =
                "{\"id\":\"id\", \"pw\":\"123\", \"email\":\"a@a.com\"}";
        RequestEntity<String> request = RequestEntity.post(URI.create("/users"))
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .body(reqBody);
        
        ResponseEntity<String> response = restTemplate.exchange(
                request,
                String.class);

        assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
        assertTrue(response.getBody().contains("WeakPasswordException"));
    }
}

스프링 부트는 테스트에서 웹 환경을 구동할 수 있는 기능을 제공한다.
이 테스트는 이 기능을 사용해서 내장 서버를 구동하고 스프링 웹 어플리케이션을 실행한다.

TestRestTemplate은 스프링 부트가 테스트 목적으로 제공하는 것으로서 내장 서버에 연결하는 RestTemplate이다. 임의의 포트를 사용해서 내장 서버를 구동하도록 설정했는데 만약 내장 서버가 12091 포트를 사용하면 http://localhost:12091/users에 POST 방식으로 요청을 전송한다.

HTTP를 이용해서 API를 호출한 결과를 검증한다. 실제 구동한 서버에 대해 HTTP로 연결해서 요청을 전송하고 응답을 받으므로 API에 대한 기능 테스트로 사용할 수 있다.

0개의 댓글