개발is:e
기타
DDD(Domain-Driven Design)
Java
Spring
TDD(Test-Driven Development)
Contact
Copyright © 2024 | LeeJae-H
Home
> TDD(Test-Driven Development)
Now Loading ...
TDD(Test-Driven Development)
테스트 코드 및 TDD의 필요성
테스트 코드의 필요성 TDD의 필요성을 살펴보기 전에 테스트 코드의 필요성부터 살펴보자. 테스트 코드를 작성해야 하는 이유에 대해서는 이미 많은 글에서 말하고 있지만, 이 글에서는 내가 지금까지 개발을 해오며 느낀 주관적인 생각을 담아 작성했다! 1. 코드의 품질 보장 테스트 코드를 작성하기 전에 가장 처음 들었던 생각은, “버그 없이 완벽하게 코드를 작성하면, 굳이 테스트 코드가 필요할까?”였다. 지금 와서 이 생각에 대한 답을 해보자면, “버그 없이 완벽하게 코드를 작성했다는 결론을 어떻게 내렸는가?”이다. 즉, 테스트 코드를 작성하고 이것이 성공했음을 보여줌으로써 코드의 품질에 대해서 말할 수 있는 것이다. 테스트 코드를 작성할 때는 다양한 시나리오를 검증하는 것이 가장 중요하다고 생각한다. 정상 동작뿐만 아니라 엣지 케이스, 예외 처리, 오류 상황 등을 충분히 테스트해야 한다. 2. 회귀 테스트로 사용 회귀 테스트는 개발하고 테스트한 소프트웨어가 이후에 코드를 수정하거나 추가해도 기존 코드가 올바르게 동작하는지 확인하기 위한 테스트이다. 간단히 말해서, 이전에 정상 동작하던 기능이 변경으로 인해 손상되지 않았는지 확인하는 테스트이다. 테스트 코드는 회귀 테스트로 사용할 수 있다. 코드는 지속적으로 수정되거나 추가되는데, 코드를 수정하거나 추가할 때 앞서 작성한 테스트 코드를 사용하면 다른 기능에 문제가 없는지 바로 확인할 수 있다. 즉, 테스트 코드는 변경한 코드로 인해 소프트웨어가 비정상적으로 동작하는 것을 사전에 막아준다. 테스트 코드가 없다면 회귀 테스트를 체계적으로 진행하기 어렵고, 더 많은 시간과 노력이 필요하다. 3. 테스트 자동화를 통한 테스트 시간 감소 REST API E2E 테스트의 경우를 보면, “직접 Postman과 같은 API 테스트 도구를 사용해서 테스트하면 되는데 굳이 테스트 코드를 작성해야 할까?”라는 생각이 들기도 한다. 이에 대한 답은 “수동으로 모든 기능을 테스트할 필요 없이, 자동화된 테스트로 빠르게 확인할 수 있다.”이다. 즉, 테스트 자동화는 테스트 시간을 줄여준다. 이전 글들에서도 이야기했듯이, “테스트를 자동화한다는 것 = 코드로 작성한 테스트를 실행한다는 것“이다. TDD 필요성 위에서 테스트 코드의 필요성에 대해 살펴보았다. 그렇다면 TDD의 필요성은 무엇일까? TDD의 필요성을 살펴보기 전에 TDD의 흐름을 다시 짚어보면, TDD는 구현 전에 테스트를 작성하고 리팩토링을 포함하는 개발 방법론이다. 1. 설계 과정을 진행 TDD 자체가 설계는 아니지만, TDD를 하다 보면 테스트 코드를 작성하는 과정에서 일부 설계를 진행하게 된다. TDD 흐름과 예시를 보면, 테스트 코드를 작성하는 과정에서 (1)클래스 이름, (2)메서드 이름, (3)메서드 파라미터, (4)실행 결과를 결정했다. 이 네 가지를 결정하는 것이 곧 설계 과정이다. 이러한 설계 과정은 내가 무엇을 만들어야 하는지 명확하게 파악할 수 있도록 해준다. 2. 디버깅 시간 감소 개발 시간은 크게 처음 코딩 시간, 테스트 시간, 디버깅 시간으로 나눌 수 있고, 이 과정은 개발을 완료할 때까지 반복된다. 전체 개발 시간을 줄이려면 코딩 시간뿐만 아니라 테스트 시간과 디버깅 시간을 줄여야 한다. 테스트 시간은 위의 테스트 코드의 필요성에서 말했듯 테스트 자동화, 즉 테스트 코드 작성을 통해 줄일 수 있다. 코드를 작성한 시점과 테스트를 하는 시점에 차이가 날수록 어떤 문제가 발생했을 때 원인을 찾는 데 시간이 필요하다. 아무리 코드를 잘 만들었다 해도 코드를 다시 읽고 분석해야 하기 때문이다. 반면에 TDD는 기능을 구현하자마자 테스트를 진행한다. 바로 전에 코드를 작성했기 때문에 테스트에 실패해도 원인을 빨리 찾을 수 있다. 즉 TDD는 디버깅 시간을 줄여준다. 사실 TDD를 적용하지 않고 구현 이후 테스트 코드를 작성할 때, 구현을 끝내고 즉시 테스트 코드를 작성한다면 꼭 TDD가 아니여도 디버깅 시간을 감소할 수 있다. 그러나, 테스트 코드 작성을 미루는 경우가 많다. 시간이 지나고 나서야 테스트 코드를 작성하고 디버깅을 진행한다면, 디버깅은 물론 테스트 코드 작성에도 많은 시간이 소요될 수 있다. 3. 지속적인 코드 정리 TDD는 리팩토링할 대상이 눈에 들어오면 리팩토링을 진행해서 코드 구조와 가독성을 개선한다. 즉, TDD는 개발 과정에서 지속적으로 코드 정리를 하므로 코드 품질이 급격히 나빠지지 않게 막아준다. 또한, 정리된 코드는 향후 코드 수정과 추가를 쉽게 할 수 있게 해주는데, 이는 곧 미래의 코딩 시간을 줄여준다. 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://www.quora.com/Is-it-necessary-to-perform-tests-in-software-development-even-if-the-code-is-written-to-never-break-down-during-operation https://stackoverflow.com/questions/247086/should-unit-tests-be-written-before-the-code-is-written https://www.growin.com/the-importance-of-testing-code
TDD(Test-Driven Development)
· 2025-01-22
REST API E2E 테스트 (feat. REST-assured)
이전 글들을 모두 읽고 오자 !! Dependencies - Spring Boot 3.4.1 - Junit 5.10.5 - REST-assured 5.3.1 REST API E2E 테스트 예시 - 스프링 부트 REST API E2E 테스트를 작성하는 것은 E2E 테스트를 자동화하는 것이다. 우리가 일반적으로 Postman을 사용하여 API를 테스트하는 것과 거의 같은 흐름이며, 예시에서는 아래 두 가지 도구를 사용한다. @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) REST-assured * @SpringBootTest의 MOCK vs RANDOM_PORT MOCK(default 값) 서블릿 컨테이너(Tomcat)를 실행하지 않는다. 즉, 서버 실행 없이 Spring Context만 로드된다. 서버가 실행되지 않기 때문에 가상으로 HTTP 요청과 응답을 테스트한다. 이를 위해, MockMvc를 사용한다. RANDOM_PORT 서블릿 컨테이너(Tomcat)를 실행한다. 즉, 임의의 포트에서 실제로 서버가 실행된다. 서버가 실행되기 때문에 실제 HTTP 요청을 보내고 응답을 받으며 테스트한다. 이를 위해, REST-assured나 TestRestTemplate 등을 사용한다. * MockMvc vs Rest-assured MockMvc DispatcherServlet을 통해 컨트롤러를 호출하고 응답을 확인한다. 일반적으로, @WebMvcTest를 사용하는 Controller 단위 테스트나 @SpringBootTest(MOCK)을 사용하는 Controller 통합 테스트에서 사용된다. Rest-assured 실제 HTTP 요청을 서버에 보내고 응답을 확인한다. 일반적으로, @SpringBootTest(RANDOM_PORT)를 사용하는 REST API E2E 테스트에서 사용된다. 0. 테스트에 사용될 객체들 @Entity @Table(name = "users") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "email") private String email; @Column(name = "password") private String password; @Builder public User(String email, String password) { this.email = email; this.password = password; } } public interface UserRepository extends Repository<User, Long> { User save(User user); Optional<User> findByEmail(String email); Optional<User> findById(Long id); } @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Transactional public Long signUp(UserSignUpRequest userSignUpRequest) { if(userRepository.findByEmail(userSignUpRequest.getEmail()).isPresent()) { throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER); } User user = User.builder() .email(userSignUpRequest.getEmail()) .password(userSignUpRequest.getPassword()) .build(); return userRepository.save(user).getId(); } public UserInfoResponse getUserInfo(Long userId) { User foundUser = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND)); return new UserInfoResponse(foundUser.getEmail()); } } @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; // 회원 가입 @PostMapping("/signup") public ResponseEntity<Long> signUp(@RequestBody UserSignUpRequest userSignUpRequest) { Long userId = userService.signUp(userSignUpRequest); return new ResponseEntity<>(userId, HttpStatus.CREATED); } // 내 정보 보기 @GetMapping("/{id}") public ResponseEntity<UserInfoResponse> getUserInfo(@PathVariable("id") Long id) { UserInfoResponse userInfo = userService.getUserInfo(id); return ResponseEntity.ok(userInfo); } } @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserSignUpRequest { private String email; private String password; public UserSignUpRequest(String email, String password) { this.email = email; this.password = password; } } @Getter public class UserInfoResponse { private String email; public UserInfoResponse(String email) { this.email = email; } } @Getter @RequiredArgsConstructor public class ExpectedException extends RuntimeException { private final ErrorCode errorCode; } @Getter @RequiredArgsConstructor public enum ErrorCode { ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."), USER_NOT_FOUND("USER_NOT_FOUND", "존재하지 않는 유저입니다."); private final String errorCode; private final String errorMessage; } 1. 스프링 부트의 내장 서버를 이용한 REST API E2E 테스트 (feat. REST-assured) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class UserApiE2ETest { @Autowired private JdbcTemplate jdbcTemplate; @Autowired private UserRepository userRepository; @LocalServerPort private int port; @BeforeEach void setUp() { jdbcTemplate.update("truncate table users"); RestAssured.port = this.port; } @Test @DisplayName("회원 가입 성공") void signUp_Success() { UserSignUpRequest signUpRequest = new UserSignUpRequest("joyuri@gmail.com", "yuri123"); RestAssured.given() .contentType(ContentType.JSON) .body(signUpRequest) .when() .post("/api/users/signup") .then() .statusCode(201) .body("", Matchers.greaterThanOrEqualTo(1)); } @Test @DisplayName("내 정보 찾기 성공") void getUserInfo_Success() { // Given User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").build(); Long userId = userRepository.save(existingUser).getId(); // When & Then RestAssured.given() .pathParam("id", userId) .when() .get("/api/users/{id}") .then() .statusCode(200) .body("email", Matchers.equalTo("joyuri@gmail.com")); } } 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://martinfowler.com/articles/practical-test-pyramid.html
TDD(Test-Driven Development)
· 2025-01-14
통합 테스트
이전 글들을 모두 읽고 오자 !! Dependencies - Spring Boot 3.4.1 - Junit 5.10.5 - WireMock 통합 테스트 예시 - 스프링 부트 스프링 부트 통합 테스트는 크게 2가지로 나눠볼 수 있다. Controller 통합 테스트 Controller와 관련된 계층(Controller, Service, Repository 등)을 통합적으로 테스트 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용 Service 통합 테스트 -> 예시에서 보여줄 테스트! Service와 관련된 계층(Service, Repository 등)을 통합적으로 테스트 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) 사용 1은 2에 비해 테스트 범위가 넓다는 장점이 있지만, 테스트 속도가 느리다는 단점이 있다. 0. 테스트에 사용될 객체들 @Entity @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "email") private String email; @Column(name = "password") private String password; @Column(name = "card_number") private String cardNumber; @Builder public User(String email, String password) { this.email = email; this.password = password; } public boolean registerCard(String cardNumber) { this.cardNumber = cardNumber; } } public interface UserRepository extends Repository<User,Long> { User save(User user); Optional<User> findByEmail(String email); Optional<User> findById(Long id); } @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final CardValidator cardValidator; @Transactional public Long signUp(String email, String password) { userRepository.findByEmail(email) .ifPresent(user -> throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER);); User user = User.builder() .email(email) .password(password) .build(); return userRepository.save(user).getId(); } @Transactional public boolean registerCard(Long userId, String cardNumber) { CardValidity cardValidity = cardValidator.validate(cardNumber); if (cardValidity == CardValidity.TIMEOUT) { throw new ExpectedException(ErrorCode.EXTERNAL_API_TIMEOUT); } if (cardValidity != CardValidity.VALID) { throw new ExpectedException(ErrorCode.INVALID_CARD_NUMBER); } User user = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND)); return user.registerCard(cardNumber); } } public enum CardValidity { INVALID, ERROR, EXPIRED, UNKNOWN, THEFT, TIMEOUT, VALID } @Service public class CardNumberalidator { private final String server; public CardNumberValidator(@Value("${card.server.url}") 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(BodyPublishers.ofString(cardNumber)) .timeout(Duration.ofSeconds(3)) .build(); try { HttpResponse<String> response = httpClient.send(request, 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 e) { return CardValidity.ERROR; } } } @Getter @RequiredArgsConstructor public class ExpectedException extends RuntimeException { private final ErrorCode errorCode; } @Getter @RequiredArgsConstructor public enum ErrorCode { ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."), USER_NOT_FOUND("USER_NOT_FOUND", "존재하지 않는 유저입니다."), INVALID_CARD_NUMBER("INVALID_CARD_NUMBER", "유효하지 않는 카드번호입니다."), EXTERNAL_API_TIMEOUT("EXTERNAL_API_TIMEOUT", "외부 API 응답 타임아웃 발생"); private final String errorCode; private final String errorMessage; } 1. 스프링 부트 통합 테스트 - 데이터베이스 @ActiveProfiles("test") @SpringBootTest // 스프링 컨테이너 초기화 public class UserServiceIntegrationTest { @Autowired private JdbcTemplate jdbcTemplate; @Autowired private UserRepository userRepository; // 대역 대신 실제 객체 사용 @Autowired private UserService userService; @BeforeEach void setUp() { jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화 } @Test @DisplayName("회원 가입 성공") void signUp_Success() { // given User user = User.builder() .email("joyuri@gmail.com") .password("yuri123") .build(); // when Long userId = userService.signUp("joyuri@gmail.com", "yuri123"); // then assertThat(userId).isGreaterThan(0); } @Test @DisplayName("회원 가입 시 이미 존재하는 유저이면 예외 발생") void signUp_AlreadyExistedUser_Then_Exception() { // given User existingUser = User.builder() .email("joyuri@gmail.com") .password("yuri123") .build(); userRepository.save(existingUser); // when & then assertThatThrownBy(() -> userService.signUp("joyuri@gmail.com", "yuri123")) .isInstanceOf(ExpectedException.class) .satisfies(e -> { assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTED_USER); } ); } } 2. 스프링 부트 통합 테스트 - 외부 API 통합 테스트하기 어려운 대상이 외부 API이다. 외부 API를 사용하는 로직에 대한 통합 테스트 작성 방법은 크게 2가지가 있다. 테스트에서 실제 외부 API 호출 외부 API를 Mocking (Stub 역할) 방법 1은 아래와 같은 불편함이 존재한다. 다양한 테스트 상황을 만들기 어렵다. (ex) 타임아웃이 발생하도록 처리 시간 설정) 일부 외부 API는 호출에 비용이 발생한다. 방법 2는 이러한 불편함을 해소할 수 있다. 외부 API를 Mocking 해주는 도구로는 WireMock이 있다. 아래의 예시처럼 WireMockServer 객체를 수동으로 관리하는 방법 대신 @AutoConfigureWireMock 어노테이션을 사용하는 방법도 있다. @AutoConfigureWireMock 예시 @ActiveProfiles("test") @SpringBootTest @TestPropertySource(properties = "card.server.url=http://localhost:8089") public class UserServiceIntegrationTest { @Autowired private JdbcTemplate jdbcTemplate; @Autowired private UserRepository userRepository; @Autowired private UserService userService; private WireMockServer wireMockServer; // WireMockServer는 HTTP 서버를 흉내냄 @BeforeEach void setUp() { jdbcTemplate.update("truncate table users"); // 테스트 전 데이터를 초기화 wireMockServer = new WireMockServer(options().port(8089)); wireMockServer.start(); // 실제 HTTP 서버가 뜸 } @AfterEach void tearDown() { wireMockServer.stop(); } @Test @DisplayName("카드 번호가 유효하면 카드 등록 성공") void valid() { // given wireMockServer.stubFor(post(urlEqualTo("/card")) .withRequestBody(equalTo("123456789")) .willReturn(aResponse() .withHeader("Content-Type", "text/plain") .withBody("ok")) ); // Stub => WireMockServer의 동작을 기술 User user = User.builder() .email("joyuri@gmail.com") .password("yuri123") .build(); Long userId = userRepository.save(user).getId(); // when boolean isValidated = userService.registerCard(userId, "123456789"); // then assertThat(isValidated).isTrue(); User savedUser = userRepository.findById(userId) .orElseThrow(() -> new ExpectedException(ErrorCode.USER_NOT_FOUND)); assertThat(savedUser.getCardNumber()).isEqualTo("123456789"); } @Test @DisplayName("외부 API 응답 타임아웃 테스트") void timeout() { // given wireMockServer.stubFor(post(urlEqualTo("/card")) .willReturn(aResponse() .withFixedDelay(5000)) // 5초 ); // Stub => WireMockServer의 동작을 기술 User user = User.builder() .email("joyuri@gmail.com") .password("yuri123") .build(); Long userId = userRepository.save(user).getId(); // when & then assertThatThrownBy(() -> userService.registerCard(userId, "123456789")) .isInstanceOf(ExpectedException.class) .satisfies(e -> { assertThat(e.getErrorCode()).isEqualTo(ErrorCode.EXTERNAL_API_TIMEOUT); } ); } } 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://martinfowler.com/articles/practical-test-pyramid.html https://techblog.woowahan.com/17674/
TDD(Test-Driven Development)
· 2025-01-11
단위 테스트
이전 글들을 모두 읽고 오자 !! Dependencies - Spring Boot 3.4.1 - Junit 5.10.5 - Mockito 단위 테스트 예시 0. 테스트에 사용될 객체들 @Entity @Table(name = "users") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "email") private String email; @Column(name = "password") private String password; @Column(name = "age") private int age; @Builder public User(String email, String password, int age) { this.email = email; this.password = password; this.age = age; } public void changeAge(int age) { this.age = age; } } public interface UserRepository extends Repository<User,Long> { User save(User user); Optional<User> findByEmail(String email); } @Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; @Transactional public Long signUp(String email, String password, int age) { userRepository.findByEmail(email) .ifPresent(user -> throw new ExpectedException(ErrorCode.ALREADY_EXISTED_USER);); User user = User.builder() .email(email) .password(password) .age(age) .build(); return userRepository.save(user).getId(); } } @RestController @RequestMapping("/api/users") @RequiredArgsConstructor public class UserController { private final UserService userService; // 회원 가입 @PostMapping("/signup") public ResponseEntity<Long> signUp(@RequestBody UserSignUpRequest signUpRequest) { Long userId = userService.signUp(signUpRequest.getEmail(), signUpRequest.getPassword(), signUpRequest.getAge()); return new ResponseEntity<>(userId, HttpStatus.CREATED); } } @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserSignUpRequest { private String email; private String password; private int age; public UserSignUpRequest(String email, String password, int age) { this.email = email; this.password = password; this.age = age; } } @Getter @RequiredArgsConstructor public class ExpectedException extends RuntimeException { private final ErrorCode errorCode; } @Getter @RequiredArgsConstructor public enum ErrorCode { ALREADY_EXISTED_USER("ALREADY_EXISTED_USER", "이미 존재하는 유저입니다."); private final String errorCode; private final String errorMessage; } 1. Domain 단위 테스트 public class UserTest { @Test @DisplayName("유저 생성") void createUser() { // when User user = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); // then assertThat(user.getEmail()).isEqualTo("joyuri@gmail.com"); assertThat(user.getPassword()).isEqualTo("yuri123"); assertThat(user.getAge()).isEqualTo(20); } @Test @DisplayName("유저 나이 변경") void changeUserAge() { // given User user = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); // when user.changeAge(25); // then assertThat(user.getAge()).isEqualTo(25); } } 2. Service 단위 테스트 @ExtendWith(MockitoExtension.class) public class UserServiceTest { @Mock private UserRepository userRepository; @InjectMocks private UserService userService; @Test @DisplayName("회원 가입 성공") void signUp_Success() { // given User userArgs = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); User userResult = User.builder() .email(userArgs.getEmail()) .password(userArgs.getPassword()) .age(userArgs.getAge()) .build(); ReflectionTestUtils.setField(userResult, "id", 3L); BDDMockito.given(userRepository.save(userArgs)) .willReturn(userResult); // Stub // when Long userId = userService.signUp(userArgs.getEmail(), userArgs.getPassword(), userArgs.getAge()); // then assertThat(userId).isEqualTo(3L); } @Test @DisplayName("회원 가입 시 이미 존재하는 유저이면 예외 발생") void signUp_AlreadyExistedUser_Then_Exception() { // given User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); BDDMockito.given(userRepository.findByEmail(existingUser.getEmail())) .willReturn(Optional.of(existingUser)); // Stub // when & then assertThatThrownBy(() -> userService.signUp(existingUser.getEmail(), existingUser.getPassword(), existingUser.getAge())) .isInstanceOf(ExpectedException.class) .satisfies(e -> { assertThat(e.getErrorCode()).isEqualTo(ErrorCode.ALREADY_EXISTED_USER); } ); } } 3. Controller 단위 테스트 & Repository 단위 테스트 Domain과 Service의 경우에는 별 다른 설정 없이 작성한 코드, 즉 순수 로직을 테스트할 수 있었다. 그러나, Controller와 Repository의 경우에는 어떨까? Controller를 테스트하기 위해서는 웹(Spring MVC) 관련 컴포넌트들이 필요하다. 이를 위해 @WebMvcTest 어노테이션을 사용한다. Repository를 테스트하기 위해서는 (JPA를 사용할 경우) JPA 관련 컴포넌트들이 필요하다. 이를 위해 @DataJpaTest 어노테이션을 사용한다. @WebMvcTest, @DataJpaTest를 사용하는 테스트는 Spring Context의 특정 부분을 로드한다고 해서 슬라이스 테스트(Slice Test)라고 불리긴 하지만, 개인적으로는 단위 테스트라고 부르고자 한다. 단위 테스트는 일반적으로 한 클래스나 한 메서드를 테스트하는 것을 의미한다. @WebMvcTest 또는 @DataJpaTest는 일부라도 Spring Context를 로드하기 때문에 테스트 범위가 더 넓어 보일 수 있지만, 본질적으로는 특정 클래스(Controller나 Repository)를 테스트하는 것이기 때문에 단위 테스트로 간주할 수 있다고 생각한다. 3-1. Controller 단위 테스트 @WebMvcTest(UserController.class) public class UserControllerTest { @MockBean // @Mock은 Spring Context에 등록되지 않으므로 userService의 의존성이 주입되지 않음 private UserService userService; @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test @DisplayName("회원 가입 성공") void signUp_Success() { // given UserSignUpRequest signUpRequest = new UserSignUpRequest("joyuri@gmail.com", "yuri123", 20); BDDMockito.given(userService.signUp(signUpRequest.getEmail(), signUpRequest.getPassword(), signUpRequest.getAge())) .willReturn(3L); // when & then mockMvc.perform(post("/api/users/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(signUpRequest))) .andExpect(status().isCreated()) .andExpect(jsonPath("$").value(3L)); } } 3-2. Repository 단위 테스트 @DataJpaTest public class UserRepositoryTest { @Autowired private UserRepository userRepository; @Test @DisplayName("유저 저장") void saveUser() { // given User newUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); // when User savedUser = userRepository.save(newUser); // then assertThat(savedUser.getId()).isGreaterThan(0L); assertThat(savedUser.getEmail()).isEqualTo(newUser.getEmail()); } @Test @DisplayName("이메일로 유저 찾기") void findUserByEmail() { // given User existingUser = User.builder().email("joyuri@gmail.com").password("yuri123").age(20).build(); userRepository.save(existingUser); // when Optional<User> foundUser = userRepository.findByEmail("joyuri@gmail.com"); // then assertThat(foundUser).isPresent(); assertThat(foundUser.get().getEmail()).isEqualTo(existingUser.getEmail()); assertThat(foundUser.get().getAge()).isEqualTo(existingUser.getAge()); } } 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://martinfowler.com/articles/practical-test-pyramid.html
TDD(Test-Driven Development)
· 2025-01-05
테스트 범위와 종류
테스트 범위에 따른 테스트 종류 일반적인 웹 애플리케이션 구조에서, 테스트 종류는 테스트 범위에 따라 세 가지로 나눠볼 수 있다. 단위 테스트(Unit Testing) 단위 테스트는 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인한다. 일반적으로, 한 클래스나 한 메서드를 테스트한다. 외부 의존성은 테스트 대역(Stub, Mock 등)으로 대체한다. 통합 테스트(Integration Testing) 통합 테스트는 시스템의 각 구성 요소가 올바르게 연동되는지 확인한다. 일반적으로, 데이터베이스, 외부 API, 프레임워크, 라이브러리, 구현한 코드 간의 연동을 테스트한다. E2E 테스트(End to end Testing) E2E 테스트는 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인한다. QA 조직에서 수행하는 테스트가 주로 E2E 테스트이다. 일반적으로, UI(웹 브라우저나 모바일 앱)을 통해 특정 사용자의 흐름이나 요구 사항을 테스트한다. E2E 테스트를 자동화하기 위한 도구는 대표적으로 Selenium과 WebDriver가 있다. 테스트를 자동화한다는 것 = 코드로 작성한 테스트를 실행한다는 것 REST API E2E 테스트 -> 애플리케이션의 UI를 통해 테스트하는 것이 매우 어려운 경우 또는 UI가 아예 없고 대신 REST API를 제공하는 경우에는 REST API에 대한 테스트를 작성하는 것이 유용하다. (feat. https://martinfowler.com/articles/practical-test-pyramid.html) 테스트 작성 요령 단위 테스트 vs 통합 테스트 테스트 상황 통합 테스트는 상황을 준비하거나 결과 확인이 어렵거나 불가능할 때가 있다. 외부 시스템과 연동해야 하는 기능이 특히 그렇다. 이런 경우에는 단위 테스트와 대역을 조합해서 상황을 만들고 결과를 확인해야 한다. 테스트 구성 통합 테스트를 실행하려면 DB와 같은 연동 대상을 구성해야 한다. 즉, 통합 테스트는 테스트 상황을 만들어내기 위해 많은 노력이 필요하다. 반면에 단위 테스트는 테스트 코드를 빼면 따로 준비할 것이 없다. 테스트 속도 통합 테스트는 DB 연결, 스프링 컨테이너 초기화와 같이 테스트 실행 속도를 느리게 만드는 요인이 많다. 반면에, 단위 테스트는 서버를 구동하거나 DB를 준비할 필요가 없다. 테스트 대상이 의존하는 기능을 대역으로 처리하면 되므로 테스트 실행 속도가 빠르다. TDD를 하는지 여부에 상관없이 개발자는 단위 테스트와 통합 테스트를 섞어서 작성한다. 어떤 테스트를 더 많이 작성해야 한다는 절대적인 규칙은 없지만, 위와 같은 차이로 통합 테스트보다는 단위 테스트를 더 많이 작성한다. 통합 테스트는 단위 테스트에 비해 준비할 것이 많고 실행 시간도 길지만, 그래도 통합 테스트는 필요하다. 아무리 단위 테스트를 많이 만든다고 해도 결국은 각 구성 요소가 올바르게 연동되는 것을 확인해야 하기 때문이다. 테스트 범위에 따른 테스트 코드 개수와 시간 E2E 테스트를 수행하려면 클라이언트부터 DB까지 모든 환경이 갖춰져야 하기 때문에 자동화하거나 다양한 상황별로 테스트하기 가장 어렵다. 또한, E2E 테스트를 수행하기 위한 알맞은 도구가 없으면 E2E 테스트 코드를 작성하기 힘들 수도 있다. 이런 이유로 정기적으로 수행하는 E2E 테스트에서는 정상적인 경우와 몇 가지 특수한 상황만 테스트한다. 통합 테스트는 E2E 테스트에 비해 제약이 덜하며, 상대적으로 실행 시간이 짧고 상황을 보다 유연하게 구성할 수 있다. 이런 이유로 보통 E2E 테스트보다 통합 테스트를 더 많이 작성한다. 단위 테스트는 통합 테스트로도 만들기 힘든 상황을 쉽게 구성할 수 있고, 더 작은 단위를 대상으로 테스트 코드를 만들 수 있다. 이런 이유로 보통 통합 테스트보다 단위 테스트를 더 많이 작성한다. E2E 테스트나 통합 테스트에서 모든 예외 상황을 테스트하면 단위 테스트는 줄어든다. 왜냐하면 각 테스트가 다루는 내용이 중복되기 때문이다. 그러나, 테스트 속도는 E2E 테스트와 통합 테스트보다 단위 테스트가 빠르기 때문에 가능하면 단위 테스트에서 다양한 상황을 다루고, E2E 테스트나 통합 테스트는 주요 상황에 초점을 맞춰야 한다. 그래야 테스트 실행 시간이 증가해 피드백이 느려지는 것을 방지할 수 있다. 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://martinfowler.com/articles/practical-test-pyramid.html https://reflectoring.io/spring-boot-web-controller-test/ https://dingdingmin-back-end-developer.tistory.com/entry/Springboot-Test-%EC%BD%94%EB%93%9C-%EC%9E%91%EC%84%B1-1 https://stackoverflow.com/questions/64541192/assertthatthrownby-check-field-on-custom-exception
TDD(Test-Driven Development)
· 2024-12-30
테스트 대역(Test Double)
Martin Fowler : 테스트 대역은 테스트를 목적으로 프로덕션 객체를 대체하는 모든 경우를 포괄하는 일반적인 용어이다. (Test Double is a generic term for any case where you replace a production object for testing purposes.) Gerard Meszaros : 테스트를 작성할 때, DOC를 사용할 수 없거나 사용하지 않기로 선택한 경우, 이를 테스트 대역으로 대체할 수 있다. 테스트 대역은 실제 DOC와 동일하게 동작할 필요는 없으며, 단지 실제 DOC와 동일한 API를 제공하여 SUT가 이를 실제 DOC로 인식하게 만들면 된다. (When we are writing a test in which we cannot (or chose not to) use a real depended-on component (DOC), we can replace it with a Test Double. The Test Double doesn’t have to behave exactly like the real DOC; it merely has to provide the same API as the real one so that the SUT thinks it is the real one!) SUT (System Under Test) - 테스트 대상 시스템을 의미한다. - 단위 테스트에서의 SUT는 테스트 대상이 되는 클래스, 객체, 메서드를 의미한다. DOC (Depended-On Component) - SUT가 의존하는 개별 클래스 또는 대규모 컴포넌트를 의미한다. 테스트 대역 종류 종류 설명 부가설명 관련검증 스텁(Stub) 테스트만을 위한 단순한 구현을 가지고 있는 객체 상태 검증(State Verification) 가짜(Fake) 프로덕션에는 적합하지 않지만 실제 동작하는 구현을 가지고 있는 객체 상태 검증 스파이(Spy) 호출된 내역을 가지고 있는 객체, Stub이기도 하다. 호출된 내역이란 테스트 중 Spy 객체의 메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지에 대한 정보이다. 상태 검증, 행위 검증 모의(Mock) 실제 객체를 흉내 내는 가짜 객체, Stub과 Spy를 대신할 수 있다. mock 객체의 주요 기능은 기대한 대로 상호작용하는지 확인(= 행위 검증)하는 것이다. 행위 검증(Behavior Verification) 테스트 대역 예시 - TDD (참고 : TDD 흐름과 예시) UserRegister(테스트 대상) : 회원 가입에 대한 핵심 로직을 수행 WeakPasswordChecker : 암호가 약한지 검사 UserRepository : 회원 정보 저장 및 조회 기능 EmailNotifier : 이메일 발송 기능 첫 번째 테스트 : 암호가 약한 경우 회원 가입에 실패 - Stub 사용 테스트 코드 작성 public class UserRegisterTest { private UserRegister userRegister; private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker(); @BeforeEach void setUp() { userRegister = new UserRegister(stubPasswordChecker); } @DisplayName("약한 암호면 가입 실패") @Test void weakPassword() { // given stubPasswordChecker.setWeak(true); // when & then assertThatThrownBy(() -> userRegister.register("id", "pw", "email")) .isInstanceOf(WeakPasswordException.class); } } 구현 public class UserRegister { private WeakPasswordChecker passwordChecker; public UserRegister(WeakPasswordChecker passwordChecker) { this.passwordChecker = passwordChecker; } public void register(String id, String pw, String email) { if(passwordChecker.checkPasswordWeak(pw)) { throw new WeakPasswordException(); } } } public interface WeakPasswordChecker { boolean checkPasswordWeak(String pw); } public class StubWeakPasswordChecker implements WeakPasswordChecker { private boolean weak; public void setWeak(boolean weak) { this.weak = weak; } @Override public boolean checkPasswordWeak(String pw) { return weak; } } public class WeakPasswordException extends RuntimeException { } 두 번째 테스트 : 동일 ID를 가진 회원이 존재할 경우 회원 가입에 실패 - Fake 사용 테스트 코드 작성 public class UserRegisterTest { private UserRegister userRegister; private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker(); private MemoryUserRepository fakeRepository = new MemoryUserRepository(); // 추가 @BeforeEach void setUp() { userRegister = new UserRegister(stubPasswordChecker, fakeRepository); // 추가 } @DisplayName("이미 같은 ID가 존재하면 가입 실패") @Test void dupIdExists() { // given fakeRepository.save(new User("id", "pw1", "email1")); // when & then assertThatThrownBy(() -> userRegister.register("id", "pw2", "email2")) .isInstanceOf(DubIdException.class); } } 구현 public class UserRegister { private WeakPasswordChecker passwordChecker; private UserRepository userRepository; // 추가 public UserRegister(WeakPasswordChecker passwordChecker, UserRepository userRepository) { this.passwordChecker = passwordChecker; this.userRepository = userRepository; // 추가 } public void register(String id, String pw, String email) { if(passwordChecker.checkPasswordWeak(pw)) { throw new WeakPasswordException(); } // 추가 User user = userRepository.findById(id); if (user != null) { throw new DupIdException(); } userRepository.save(new User(id, pw, email)); } } public interface UserRepository { void save(User user); User findById(String id); } public class MemoryUserRepository implements UserRepository { private Map<String, User> users = new HashMap<>(); @Override public void save(User user) { users.put(user.getId(), user); } @Override public User findById(String id) { return users.get(id); } } public class DubIdException extends RuntimeException { } 세 번째 테스트 : 회원 가입 성공 시 메일 발송 - Spy 사용 이메일 발송 여부를 어떻게 확인할 수 있을까? 이를 확인할 수 있는 방법 중 하나는, UserRegister가 EmailNotifier의 메일 발송 기능을 실행할 때 이메일 주소로 “email@naver.com”을 사용했는지 확인하는 것이다. 이런 용도로 사용할 수 있는 것이 스파이(Spy) 대역이다. 테스트 코드 작성 public class UserRegisterTest { private UserRegister userRegister; private StubWeakPasswordChecker stubPasswordChecker = new StubWeakPasswordChecker(); private MemoryUserRepository fakeRepository = new MemoryUserRepository(); private SpyEmailNotifier spyEmailNotifier = new SpyEmailNotifier(); // 추가 @BeforeEach void setUp() { userRegister = new UserRegister(stubPasswordChecker, fakeRepository, spyEmailNotifier); // 추가 } @DisplayName("가입하면 메일을 전송함") @Test void whenRegisterThenSendMail() { // when userRegister.register("id", "pw", "email@naver.com"); // then // sendRegisterEmail가 호출되었는지 확인 assertThat(spyEmailNotifier.isCalled()).isTrue(); // sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인 assertThat(spyEmailNotifier.getEmail()).isEqualTo("email@naver.com"); } } 구현 public class UserRegister { private WeakPasswordChecker passwordChecker; private UserRepository userRepository; private EmailNotifier emailNotifier; // 추가 public UserRegister(WeakPasswordChecker passwordChecker, UserRepository userRepository, EmailNotifier emailNotifier) { this.passwordChecker = passwordChecker; this.userRepository = userRepository; this.emailNotifier = emailNotifier; // 추가 } public void register(String id, String pw, String email) { if(passwordChecker.checkPasswordWeak(pw)) { throw new WeakPasswordException(); } User user = userRepository.findById(id); if (user != null) { throw new DupIdException(); } userRepository.save(new User(id, pw, email)); emailNotifier.sendRegisterEmail(email); // 추가 } } public interface EmailNotifier { void sendRegisterEmail(String email); } public class SpyEmailNotifier implements EmailNotifier { private boolean called; private String email; public boolean isCalled() { return called; } public String getEmail() { return email; } @Override public void sendRegisterEmail(String email) { this.called = true; this.email = email; } } Stub과 Spy 대체 - Mock 사용 public class UserRegisterTest { private UserRegister userRegister; private WeakPasswordChecker mockPasswordChecker = Mockito.mock(WeakPasswordChecker.class); // 수정 private MemoryUserRepository fakeRepository = new MemoryUserRepository(); private EmailNotifier mockEmailNotifier = Mockito.mock(EmailNotifier.class); // 수정 @BeforeEach void setUp() { userRegister = new UserRegister(mockPasswordChecker, fakeRepository, mockEmailNotifier); // 수정 } // Mock 객체를 사용하여 Stub을 대신함 @DisplayName("약한 암호면 가입 실패") @Test void weakPassword() { // given BDDMockito.given(mockPasswordChecker.checkPasswordWeak("pw")) .willReturn(true); // when & then assertThatThrownBy(() -> userRegister.register("id", "pw", "email")) .isInstanceOf(WeakPasswordException.class); } // Mock 객체를 사용하여 Spy를 대신함 @DisplayName("가입하면 메일을 전송함") @Test void whenRegisterThenSendMail() { // when userRegister.register("id", "pw", "email@naver.com"); // then ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); // sendRegisterEmail가 호출되었는지 확인 + 인자를 captor에 저장 BDDMockito.then(mockEmailNotifier) .should().sendRegisterEmail(captor.capture()); String realEmail = captor.getValue(); // sendRegisterEmail가 호출될 때, 인자로 "email@naver.com"가 사용되었는지 확인 assertThat(realEmail).isEqualTo("email@naver.com"); } } Behavior Verification - Mock 사용 @DisplayName("회원 가입시 암호 검사 수행함") @Test void checkPassword() { // when userRegister.register("id", "pw", "email"); // then BDDMockito.then(mockPasswordChecker) // 해당 Mock 객체의 .should() // 특정 메서드가 호출됐는지 검증하는데 .checkPasswordWeak(BDDMockito.anyString()); // 임의의 String 타입 인자를 이용해서 checkPasswordWeak 메서드 호출 여부 확인 } 대역 사용의 이점과 필요성 대기 시간을 줄여준다. ex) 다른 개발자가 A 기능을 구현하고 있을 때, 구현이 완료될 때까지 기다리지 않고 A 기능을 테스트할 수 있다. ex) DB 서버가 구축되기 전에도 데이터가 올바르게 저장되는지 확인할 수 있다. ex) 회원 가입 성공 시 메일 발송 여부를 확인할 때, 메일함에 메일이 도착할 때까지 기다릴 필요가 없다. 다양한 상황에 대해 테스트할 수 있다. ex) 외부 API를 사용할 때, 해당 API 서버의 응답을 5초 이내에 받지 못하는 상황을 테스트해볼 수 있다. 테스트 수행이 외부에 영향을 주면 안되는 경우, 대역을 통해 해결할 수 있다. ex) 은행 계좌에서 돈을 인출하는 기능을 테스트할 때, 실제 인출 요청이 API 서버에 전달되어서는 안된다. 테스트 내에서 같은 요청을 보냈을 때 외부의 응답이 항상 동일할 것이라고 신뢰할 수 없는 경우, 대역을 통해 해결할 수 있다. ex) 발급받은 오픈 API의 Key가 만료되면 응답이 달라진다. ex) 외부 API 서버에 장애가 발생하면 응답이 달라진다. 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://martinfowler.com/bliki/TestDouble.html http://xunitpatterns.com/Test%20Double.html http://xunitpatterns.com/SUT.html http://xunitpatterns.com/DOC.html https://gyuwon.github.io/blog/2020/05/10/do-you-really-need-test-doubles.html https://beomseok95.tistory.com/295 https://jonghoonpark.com/2023/12/05/test-double-for-well-grounded-java-developer https://greeng00se.github.io/test-double https://umbum.dev/1233/ https://f-lab.kr/insight/test-code-and-mock-object
TDD(Test-Driven Development)
· 2024-12-23
테스트 코드의 구성
테스트 코드의 구성 요소 : 상황, 실행, 결과 확인 테스트 코드는 기능을 실행하고 그 결과를 확인하므로 상황, 실행, 결과 확인의 세 가지 요소로 테스트를 구성할 수 있다. 어떤 상황이 주어지고(given), 그 상황에서 기능을 실행하고(when), 실행한 결과를 확인하는(then) 세 가지가 테스트 코드의 기본 골격을 이루게 된다. 기능은 상황에 따라 결과가 달라진다. 상황에 따라 다르게 동작하는 예시(숫자 야구 게임)를 살펴보자. 숫자 야구 게임은 0~9 사이의 서로 다른 숫자 세 개를 고르면 상대방이 그 숫자를 맞추면 게임이다. 즉, 이 게임에서의 상황은 정답 숫자이다. @Test void exactMatch() { // 정답이 456인 상황 (= given) BaseballGame game = new BaseballGame("456"); // 실행 (= when) Score score = game.guess("456"); // 결과 확인 (= then) assertThat(score.strike()).isEqaulTo(3); } @Test void noMatch() { // 정답이 123인 상황 (= given) BaseballGame game = new BaseballGame("123"); // 실행 (= when) Score score = game.guess("456"); // 결과 확인 (= then) assertThat(score.strike()).isEqaulTo(0); } 추가로, 여러 테스트에서 같은 상황이 있으면 @BeforeEach를 적용한 메서드에서 상황을 설정하는 것이 좋다. 상황이 없는 경우도 존재한다. TDD 흐름과 예시에서 본 예제가 이에 해당한다. 암호 강도 측정의 경우 결과에 영향을 주는 상황이 존재하지 않으므로 테스트는 다음처럼 기능을 실행하고 결과를 확인하는 코드만 포함하고 있다. @Test void meetsAllCriteria_Then_Strong() { // 실행 (= when) PasswordStrengthMeter meter = new PasswordStrengthMeter(); PasswordStrength result = meter.meter("ab12!@AB"); // 결과 확인 (= then) assertThat(result).isEqualTo(PasswordStrength.STRONG); } 또한, 실행 결과가 항상 리턴 값으로 존재하는 것은 아니다. 실행 결과로 예외를 발생하는 것이 정상인 경우도 있다. @Test void game_With_DupNumber_Then_Fail() { assertThatThrownBy(() -> new BaseballGame("110")) .isInstanceOf(IllegalArgumentException.class); } 상황(given)-실행(when)-결과 확인(then) 구조에 너무 집착하지는 말자. 이 구조가 테스트 코드를 작성하는데 도움이 되는 것은 맞지만 꼭 모든 테스트 메서드를 이 구조로 만들어야 하는 것은 아니다. 테스트 코드를 보고 테스트 내용을 이해할 수 있으면 된다. 상황(given)에 대한 나의 생각 상황을 좀 더 구체적으로 말하자면, 결과에 영향을 주는 상황이다. 아래의 예시를 한번 보자. @ExtendWith(MockitoExtension.class) class UserServiceTest { @InjectMocks private UserService userService; @Mock private UserRepository userRepository; // UserRepository의 대역으로 Mock 사용 @Test @DisplayName("회원 조회") void findUser() { // given Long userId = 1L; User user = User.builder() .userId(userId) .name("joyuri") .build(); BDDMockito.given(userRepository.findByUserId(userId)) .willReturn(user); // Mock 객체가 Stub의 역할을 대신함 // when User findUser = userService.findUser(userId); // then assertThat(findUser.getUserId()).isEqualTo(user.getUserId()); assertThat(findUser.getName()).isEqualTo(user.getName()); } } BDDMockito.given()는 userService.findUser() 기능에 영향을 주고, 이는 곧 결과에 영향을 주므로 “결과에 영향을 주는 상황”의 의미가 잘 들어맞는다. 그런데 Long userId = 1L과 User user = User.builder()... 부분은 어떨까? 이 부분은 “결과에 영향을 주는 상황”이라고 말하기에는 조금 애매해보인다. 위의 예시를 아래와 같이 하드 코딩하여 수정해보았다. 테스트는 문제 없이 잘 작동한다. @Test @DisplayName("회원 조회") void findUser() { // given BDDMockito.given(userRepository.findByUserId(1L)) .willReturn(User.builder() .userId(userId) .name("joyuri") .build();); // when User findUser = userService.findUser(1L); // then assertThat(findUser.getUserId()).isEqualTo(1L); assertThat(findUser.getName()).isEqualTo("joyuri"); } 즉, Long userId = 1L과 User user = User.builder()... 부분은 “결과에 영향을 주는 상황”이라기보다는, when과 then에서의 “하드 코딩을 피하기 위해 변수를 선언하고 초기화한 것”의 의미가 더 적합해보인다. 그래서, 나의 결론은 다음과 같다. 테스트 코드도 코드이기 때문에, 하드 코딩보다는 변수를 활용하는 것이 좋다. 그러면 변수를 선언하고 초기화하는 부분은 어디에 작성할까? given 부분에 작성하지 말고 다른 부분에 작성하는 것보다는, 이 부분도 그냥 given 부분에 작성하는 것이 좋아보인다. given-when-then 구조를 유지할 수 있고, 일반적으로 많은 사람들도 given 부분에 작성하기 때문이다! 외부 상황과 외부 결과 상황에는 외부 요인도 있다. 상황에 영향을 주는 외부 요인은 파일, DBMS, 외부 서버 등 다양하다. 또한, 외부에서 결과를 확인해야 할 때도 있다. 예를 들어, 처리 결과를 지정한 경로의 파일에 저장하는 기능의 경우, 이 기능을 실행한 결과를 검증하려면 해당 경로에 파일이 원하는 내용으로 만들어졌는지 확인해야 한다. 외부 상황 예시 1 : 파일에서 데이터를 읽어오는 경우 @Test void dataFileSumTest() { // given File dataFile = new File("src/test/resources/datafile.txt"); // when long sum = MathUtils.sum(dataFile); // then assertThat(sum).isEqualTo(6L); } 이 경우 다음 데이터를 갖는 “datafile.txt” 파일을 해당 경로에 미리 만들어야 하며, 다른 개발자도 테스트를 실행할 수 있어야 하므로 해당 파일은 버전 관리 대상에 추가해야 한다. // datafile.txt 1 2 3 외부 상황 예시 2 : 계좌 번호 검증을 위해 외부 API를 사용하는 경우 @Test void isValidAccountTest() throws Exception { // given String apiUrl = "https://example.com/api"; AccountValidator validator = new AccountValidator(apiUrl); // when boolean isValid = validator.isValidAccount("123456-78-910"); // then assertThat(isValid).isTrue(); } 위와 같이 REST API 응답 결과가 유효한(또는 유효하지 않은) 계좌 번호인 상황은 테스트해볼 수 있지만, 아래의 상황들은 테스트해볼 수 없다. REST API 서버에 연결할 수 없는 상황 REST API 서버에서 응답을 5초 이내에 받지 못하는 상황 이처럼 테스트 대상의 상황과 결과에 외부 요인이 관여할 경우에, 대역을 사용하면 테스트 작성이 쉬워진다. 대역은 테스트 대상이 의존하는 대상의 실제 구현을 대신하는 구현인데, 이 대역을 통해서 외부 상황이나 결과를 대체할 수 있다. 참고 자료 테스트 주도 개발 시작하기(저자: 최범균)
TDD(Test-Driven Development)
· 2024-12-11
TDD 흐름과 예시
TDD(Test-Driven Development)는 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스이다. 먼저, 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다. 마지막으로 작성한 코드를 표준에 맞도록 리팩토링한다. (feat. 위키백과) TDD 흐름 테스트(Red) : 기능을 검증하는 테스트 코드를 먼저 작성한다. 테스트에 대한 구현을 하지 않았으므로 실패해야 한다. 실패 예시 : 존재하지 않는 객체나 메서드 등을 작성하여 실패(컴파일 에러), 객체나 메서드가 이미 존재하지만 테스트할 상황에 대한 구현이 되어 있지 않아서 실패 등 코딩(Green) : 테스트를 통과할 만큼만 코드를 작성한다. 지금까지 작성한 테스트들을 통과할 만큼만 구현을 진행하며, 아직 추가하지 않은 테스트들을 고려하지 않는다. 리팩토링(Refactor) : 테스트를 통과한 뒤에는 개선할 코드가 있으면 리팩토링한다. 리팩토링을 수행한 뒤에는 다시 테스트를 실행해서 기존 기능이 망가지지 않았는지 확인한다. 테스트 코드도 코드이기 때문에 유지보수 대상이다. 즉, 테스트 메서드에서 발생하는 중복을 제거하거나 의미가 잘 드러나게 코드를 수정할 필요가 있다. 그러나 테스트 코드의 중복을 무턱대고 제거하면 안된다. 중복을 제거한 뒤에도 테스트 코드의 가독성이 떨어지지 않고 수정이 용이한 경우에만 중복을 제거해야 한다. => 이 과정들을 반복하면서 점진적으로 기능을 완성해 나가는 것이 전형적인 TDD의 흐름이며, 테스트 코드를 먼저 작성함으로써 테스트가 개발을 주도하게 된다 TDD 예시(단위 테스트) - 암호 검사기 암호 검사기 기능을 TDD로 구현해보자. 암호 검사기는 문자열을 검사해서 규칙을 준수하는지에 따라 암호를 “강함”, “보통”, “약함”으로 구분한다. 검사할 규칙은 다음 두 가지이다. 길이가 8글자 이상 0부터 9 사이의 숫자를 포함 암호의 구분은 다음과 같다. 2개의 규칙 모두 충족 => “강함” 1개의 규칙 충족 => “보통” 0개의 규칙 충족 => “약함” 첫 번째 테스트를 잘 선택하는 것이 중요하다. 첫 번째 테스트를 선택할 때에는 가장 쉽거나 가장 예외적인 상황을 선택해야 한다. 모든 규칙을 충족하는 경우 -> 해당 상황을 선택! 모든 규칙을 충족하지 않는 경우 값이 없는 경우(null 또는 빈 문자열) 첫 번째 테스트 : 모든 규칙을 충족하는 경우 테스트 코드 작성 테스트할 기능을 제공할 클래스의 이름, 테스트 메서드 이름, 메서드의 리턴 타입 등을 결정했다. PasswordStrengthMeter 타입과 PasswordStrength 타입이 존재하지 않으므로 컴파일 에러가 발생한다. public class PasswordStrengthMeterTest { @Test void meetsAllCriteria_Then_Strong() { PasswordStrengthMeter meter = new PasswordStrengthMeter(); PasswordStrength result = meter.meter("ab12!@AB"); assertThat(result).isEqualTo(PasswordStrength.STRONG); } } 구현 지금까지 작성한 테스트들을 통과할 만큼만 구현한다. public enum PasswordStrength { STRONG } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { return PasswordStrength.STRONG; } } 리팩토링 아직 개선할 부분이 없어 보인다! 두 번째 테스트 : 길이만 8글자 미만이고 나머지 조건은 충족하는 경우 테스트 코드 작성 PasswordStrength 열거 타입에 NORMAL이 없으므로 컴파일 에러가 발생하고, 암호의 글자수 확인에 대한 구현이 되어 있지 않아 실패한다. public class PasswordStrengthMeterTest { @Test void meetsOtherCriteria_except_for_length_Then_Normal() { PasswordStrengthMeter meter = new PasswordStrengthMeter(); PasswordStrength result = meter.meter("ab12!@A"); assertThat(result).isEqualTo(PasswordStrength.NORMAL); } } 구현 지금까지 작성한 테스트들을 통과할 만큼만 구현한다. public enum PasswordStrength { STRONG, NORMAL } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { // 추가 if (s.length() < 8) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } } 리팩토링 아직 개선할 부분이 없어 보인다! 세 번째 테스트 : 숫자를 포함하지 않고 나머지 조건은 충족하는 경우 테스트 코드 작성 컴파일 에러는 발생하지 않지만, 암호의 숫자 포함 여부에 대한 구현이 되어 있지 않아 실패한다. public class PasswordStrengthMeterTest { @Test void meetsOtherCriteria_except_for_number_Then_Normal() { PasswordStrengthMeter meter = new PasswordStrengthMeter(); PasswordStrength result = meter.meter("ab!@ABCDEF"); assertThat(result).isEqualTo(PasswordStrength.NORMAL); } } 구현 지금까지 작성한 테스트들을 통과할 만큼만 구현한다. public enum PasswordStrength { STRONG, NORMAL } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { if (s.length() < 8) return PasswordStrength.NORMAL; // 추가 boolean containsNum = false; for (char ch : s.toCharArray()) { if (ch >= '0' && ch <= '9') { containsNum = true; break; } } if (!containsNum) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } } 리팩토링 세 개의 테스트 메서드에서 PasswordStrengthMeter 객체를 생성하는 코드의 중복, 기능을 실행하고 이를 확인하는 코드의 중복을 제거했다. 숫자 포함 여부를 확인하는 코드가 다소 길어지므로 해당 코드를 메서드로 추출해서 가독성을 개선하고 매서드 길이도 줄였다. public class PasswordStrengthMeterTest { private PasswordStrengthMeter meter = new PasswordStrengthMeter(); @Test void meetsAllCriteria_Then_Strong() { assertStrength("ab12!@AB"); } @Test void meetsOtherCriteria_except_for_length_Then_Normal() { assertStrength("ab12!@A"); } @Test void meetsOtherCriteria_except_for_number_Then_Normal() { assertStrength("ab!@ABCDEF"); } private void assertStrength(String password, PasswordStrength pwstr) { PasswordStrength result = meter.meter(password); assertThat(result).isEqualTo(pwstr); } } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { if (s.length() < 8) return PasswordStrength.NORMAL; // 수정 boolean containsNum = meetsContainingNumberCriteria(s); if(!containsNum) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } // 추가 private boolean meetsContainingNumberCriteria(String s) { for (char ch : s.toCharArray()) { if (ch >= '0' && ch <= '9') { return true } } return false; } } 네 번째 테스트 : 값이 없는 경우(null 또는 빈 문자열) 테스트 코드 작성 null 또는 빈 문자열을 입력할 경우에, IllegalArgumentException 발생시키는 방법과 유효하지 않은 암호를 의미하는 PasswordStrength.INVALID를 리턴하는 방법을 떠올렸고 이 중에 후자의 방법을 선택했다. PasswordStrength 열거 타입에 INVALID이 없으므로 컴파일 에러가 발생하고, 값이 없는 상황에 대한 구현이 되어 있지 않아 실패한다. public class PasswordStrengthMeterTest { @Test void nullOrEmptyInput_Then_Invalid() { assertStrength(null, PasswordStrength.INVALID); assertStrength("", PasswordStrength.INVALID); } } 구현 지금까지 작성한 테스트들을 통과할 만큼만 구현한다. public enum PasswordStrength { STRONG, NORMAL, INVALID } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { //추가 if (s == null || s.isEmpty()) return PasswordStrength.INVALID; if (s.length() < 8) return PasswordStrength.NORMAL; boolean containsNum = meetsContainingNumberCriteria(s); if(!containsNum) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } } 리팩토링 개선할 부분이 없어 보인다! 다섯 번째 테스트 : 모든 규칙을 충족하지 않는 경우 테스트 코드 작성 PasswordStrength 열거 타입에 WEAK가 없으므로 컴파일 에러가 발생하고, 모든 규칙을 충족하지 않는 상황에 대한 구현이 되어 있지 않아 실패한다. public class PasswordStrengthMeterTest { @Test void meetsNoCriteria_Then_Weak() { assertStrength("abc", PasswordStrength.WEAK); } } 구현 지금까지 작성한 테스트들을 통과할 만큼만 구현한다. WEAK를 리턴하기 위해 꽤 많은(?) 코드를 수정했다. public enum PasswordStrength { STRONG, NORMAL, INVALID, WEAK } public class PasswordStrengthMeter { public PasswordStrength meter(String s) { if (s == null || s.isEmpty()) return PasswordStrength.INVALID; boolean lengthEnough = s.length() >= 8; // 수정 boolean containsNum = meetsContainingNumberCriteria(s); // 추가 if(!lengthEnough && !containsNum) return PasswordStrength.WEAK; if(!lengthEnough) return PasswordStrength.NORMAL; // 수정 if(!containsNum) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } } 리팩토링 지금까지의 코드도 전체적으로 나쁘진 않았지만, “~개의 규칙을 충족하면 강도가 ~이다.”라는 느낌을 주도록(코드 가독성 개선) 코드를 리팩토링했다. public class PasswordStrengthMeter { public PasswordStrength meter(String s) { if (s == null || s.isEmpty()) return PasswordStrength.INVALID; int metCounts = getMetCriteriaCounts(s); if(metCounts == 0) return PasswordStrength.WEAK; if(metCounts == 1) return PasswordStrength.NORMAL; return PasswordStrength.STRONG; } private int getMetCriteriaCounts(String s) { int metCounts = 0; if (s.length() >= 8) metCounts++; if (meetsContainingNumberCriteria(s)) metCounts++; return metCounts; } } 테스트에서 메인으로 코드 이동 마지막으로, src/test/java 소스 폴더에 위치한 PasswordStrength.java 파일과 PasswordStrengthMeter.java 파일을 배포 대상인 src/main/java로 이동해야 비로서 구현이 끝난다. 테스트가 개발을 주도 가장 먼저 통과해야 할 테스트를 작성했다. 이 과정에서 구현을 생각하지 않았고, 테스트를 추가한 뒤 지금까지 작성한 테스트들을 통과시킬 만큼 기능을 구현했다. 테스트 코드가 추가되면서 검증하는 범위가 넓어질수록 구현도 점점 완성되어간다. -> 이렇게 테스트가 개발을 주도해 나간다. 참고 자료 테스트 주도 개발 시작하기(저자: 최범균) https://tech.inflab.com/20230404-test-code/ https://jayhooney.github.io/tdd/TDD-part1/
TDD(Test-Driven Development)
· 2024-12-08
<
>
Touch background to close