세션7. TDD 주기 반복
1. 테스트와 코드의 성장
두 번째 테스트 시나리오
email
속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다.
테스트가 유도하는 시스템 코드의 성장
테스트가 추가되며 시스템 코드는 성장했습니다.
첫 번째 테스트 추가
SellerSignUpController
형식 추가signUp
메서드 추가signUp
메서드 반환 형식 설계
두 번째 테스트 추가
signUp
메서드에 매개변수 추가HTTP 요청 본문에 따른 분기 추가
테스트가 유도한 코드에 문제가 발생하면 테스트는 경고신호를 보낸다.
테스트 코드를 추가하고 SellerSignUpController
에 테스트에 해당하는 비지니스 로직을 추가하자.
SellerSignUpController
에 테스트에 해당하는 비지니스 로직을 추가하자. package test.commerce.api.seller.signup;
import commerce.CommerceApiApp;
import commerce.command.CreateSellerCommand;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(
classes = CommerceApiApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@DisplayName("POST /seller/signUp")
public class POST_specs {
...
@Test
void email_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다(
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
null,
"seller",
"password"
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
}
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
if (command.email() == null) {
return ResponseEntity.badRequest().build();
} else {
return ResponseEntity.noContent().build();
}
}
}
2. 매개변수화 테스트
입력 데이터를 효율적으로 검사하기 위해 @ParameterizedTest
, @ValueSource
를 사용해 매개변수화 테스트 기법을 사용한다.
package test.commerce.api.seller.signup;
import commerce.CommerceApiApp;
import commerce.command.CreateSellerCommand;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(
classes = CommerceApiApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@DisplayName("POST /seller/signUp")
public class POST_specs {
...
@ParameterizedTest
@ValueSource(strings = {
"invalid-email",
"invalid-email@",
"invalid-email@test",
"invalid-email@test.",
"invalid-email@.com"
})
void email_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다(
String email,
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
email,
"seller",
"password"
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
}
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$";
if (command.email() == null) {
return ResponseEntity.badRequest().build();
} else if (command.email().contains("@") == false) {
return ResponseEntity.badRequest().build();
} else if (command.email().endsWith("@")) {
return ResponseEntity.badRequest().build();
} else if (command.email().matches(emailRegex) == false) {
return ResponseEntity.badRequest().build();
} else {
return ResponseEntity.noContent().build();
}
}
}
3. 누락된 테스트 시나리오 발견
테스트 시나리오 목록을 작성한 후 시스템을 구현하는 과정에서 목록에 포함하지 못했던 시나리오가 발견되는 상황을 경험하자.
새로 발견한 테스트 시나리오
사용자이름 검사 논리를 구현하는 과정에서,
"^[a-z]*$"
정규식은 처음 작성된 테스트 시나리오 목록 구현에는 충분한 코드이지만 사용자이름 정책을 충분하게 반영하지는 않는다는 것을 발견했습니다.이 문제를 즉시 다루기 보다는 우선 관련된 새로운 테스트 시나리오를 목록에 추가해 놓고 현재 작업에 집중했습니다.
package test.commerce.api.seller.signup;
import commerce.CommerceApiApp;
import commerce.command.CreateSellerCommand;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(
classes = CommerceApiApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@DisplayName("POST /seller/signUp")
public class POST_specs {
...
@Test
void username_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다(
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
"seller@test.com",
null,
"password"
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
@ParameterizedTest
@ValueSource(strings = {
"",
"se",
"seller ",
"seller.",
"seller!",
"seller@"
})
void username_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다(
String username,
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
"seller@test.com",
username,
"password"
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
}
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$";
String usernameRegex = "^[a-z]*$";
if (command.email() == null) {
return ResponseEntity.badRequest().build();
} else if (command.email().contains("@") == false) {
return ResponseEntity.badRequest().build();
} else if (command.email().endsWith("@")) {
return ResponseEntity.badRequest().build();
} else if (command.email().matches(emailRegex) == false) {
return ResponseEntity.badRequest().build();
} else if (command.username() == null) {
return ResponseEntity.badRequest().build();
} else if (command.username().isBlank()) {
return ResponseEntity.badRequest().build();
} else if (command.username().length() < 3) {
return ResponseEntity.badRequest().build();
} else if (command.username().matches(usernameRegex) == false) {
return ResponseEntity.badRequest().build();
} else {
return ResponseEntity.noContent().build();
}
}
}
...
테스트
- [x] 올바르게 요청하면 204 No Content 상태코드를 반환한다
- [x] email 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다
- [x] email 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다
- [x] username 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다
- [x] username 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다
- [ ] username 속성이 올바른 형식을 따르면 204 No Content 상태코드를 반환한다 // 추가된 시나리오
- [ ] password 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다
- [ ] password 속성이 올바른 형식을 따르지 않으면 400 Bad Request 상태코드를 반환한다
- [ ] email 속성에 이미 존재하는 이메일 주소가 지정되면 400 Bad Request 상태코드를 반환한다
- [ ] username 속성에 이미 존재하는 사용자이름이 지정되면 400 Bad Request 상태코드를 반환한다
- [ ] 비밀번호를 올바르게 암호화한다
4. 올바른 입력 데이터 집합
시스템 정책을 준수하는 여러 테스트 데이터를 사용해 테스트 시나리오를 구현하자.
package test.commerce.api.seller.signup;
import commerce.CommerceApiApp;
import commerce.command.CreateSellerCommand;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(
classes = CommerceApiApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@DisplayName("POST /seller/signUp")
public class POST_specs {
...
@ParameterizedTest
@ValueSource(strings = {
"seller",
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
"0123456789",
"seller_",
"seller-"
})
void username_속성이_올바른_형식을_따르면_204_No_Content_상태코드를_반환한다(
String username,
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
"seller@test.com",
username,
"password"
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(204);
}
}
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$";
String usernameRegex = "^[a-zA-Z0-9_-]*$";
if (command.email() == null) {
return ResponseEntity.badRequest().build();
} else if (command.email().contains("@") == false) {
return ResponseEntity.badRequest().build();
} else if (command.email().endsWith("@")) {
return ResponseEntity.badRequest().build();
} else if (command.email().matches(emailRegex) == false) {
return ResponseEntity.badRequest().build();
} else if (command.username() == null) {
return ResponseEntity.badRequest().build();
} else if (command.username().isBlank()) {
return ResponseEntity.badRequest().build();
} else if (command.username().length() < 3) {
return ResponseEntity.badRequest().build();
} else if (command.username().matches(usernameRegex) == false) {
return ResponseEntity.badRequest().build();
} else {
return ResponseEntity.noContent().build();
}
}
}
5. 관성을 따르는 코드 구현
지금까지 작성했던 코드를 바탕으로 입력 데이터를 검사하는 테스트 시나리오를 관성에 따라서 구현해보자.
package test.commerce.api.seller.signup;
import commerce.CommerceApiApp;
import commerce.command.CreateSellerCommand;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(
classes = CommerceApiApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
@DisplayName("POST /seller/signUp")
public class POST_specs {
...
@Test
void password_속성이_지정되지_않으면_400_Bad_Request_상태코드를_반환한다(
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
"seller@test.com",
"seller",
null
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
@ParameterizedTest
@ValueSource(strings = {
"",
"pass",
"pass123"
})
void password_속성이_올바른_형식을_따르지_않으면_400_Bad_Request_상태코드를_반환한다(
String password,
@Autowired TestRestTemplate client
) {
// Arrange
var command = new CreateSellerCommand(
"seller@test.com",
"seller",
password
);
// Act
ResponseEntity<Void> response = client.postForEntity(
"/seller/signUp",
command,
Void.class
);
// Assert
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
}
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$";
String usernameRegex = "^[a-zA-Z0-9_-]*$";
if (command.email() == null) {
return ResponseEntity.badRequest().build();
} else if (command.email().contains("@") == false) {
return ResponseEntity.badRequest().build();
} else if (command.email().endsWith("@")) {
return ResponseEntity.badRequest().build();
} else if (command.email().matches(emailRegex) == false) {
return ResponseEntity.badRequest().build();
} else if (command.username() == null) {
return ResponseEntity.badRequest().build();
} else if (command.username().isBlank()) {
return ResponseEntity.badRequest().build();
} else if (command.username().length() < 3) {
return ResponseEntity.badRequest().build();
} else if (command.username().matches(usernameRegex) == false) {
return ResponseEntity.badRequest().build();
} else if (command.password() == null) {
return ResponseEntity.badRequest().build();
} else if (command.password().length() < 8) {
return ResponseEntity.badRequest().build();
} else {
return ResponseEntity.noContent().build();
}
}
}
6. 리팩토링
리팩터링과 도구
여러 번 중첩된
else if
문으로 인해 읽기 어려워진 구현 코드의 설계를 개선해 가독성을 높였다.리팩터링 과정에서 여러 차례 테스트를 실행하며 안정감을 유지했다.
직접 코드를 수정하기 보다는 가능하면 도구가 제공하는 기능을 사용해 리팩터링 속도는 높이고 수작업의 실수는 최소화했다.
리팩터링 후 테스트 시 문제 없다.
package commerce.api.controller;
import commerce.command.CreateSellerCommand;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public record SellerSignUpController() {
public static final String EMAIL_REGEX = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$";
public static final String USERNAME_REGEX = "^[a-zA-Z0-9_-]{3,}$";
@PostMapping("/seller/signUp")
ResponseEntity<?> signUp(@RequestBody CreateSellerCommand command) {
if (isCommandValid(command) == false) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.noContent().build();
}
private static boolean isCommandValid(CreateSellerCommand command) {
return isEmailValid(command.email())
&& isUsernameValid(command.username())
&& isPasswordValid(command.password());
}
private static boolean isEmailValid(String email) {
return email != null && email.matches(EMAIL_REGEX);
}
private static boolean isUsernameValid(String username) {
return username != null && username.matches(USERNAME_REGEX);
}
private static boolean isPasswordValid(String password) {
return password != null && password.length() >= 8;
}
}
Last updated