# 세션7. TDD 주기 반복

## 1. 테스트와 코드의 성장&#x20;

### 두 번째 테스트 시나리오&#x20;

`email` 속성이 지정되지 않으면 400 Bad Request 상태코드를 반환한다.

### 테스트가 유도하는 시스템 코드의 성장&#x20;

**테스트가 추가되며 시스템 코드는 성장했습니다.**

1. 첫 번째 테스트 추가
   1. `SellerSignUpController` 형식 추가
   2. `signUp` 메서드 추가
   3. `signUp` 메서드 반환 형식 설계
2. 두 번째 테스트 추가
   1. `signUp` 메서드에 매개변수 추가
   2. HTTP 요청 본문에 따른 분기 추가

**테스트가 유도한 코드에 문제가 발생하면 테스트는 경고신호를 보낸다.**&#x20;

#### 테스트 코드를 추가하고 `SellerSignUpController` 에 테스트에 해당하는 비지니스 로직을 추가하자.&#x20;

```java
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);
    }
}
```

```java
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. 매개변수화 테스트&#x20;

입력 데이터를 효율적으로 검사하기 위해 `@ParameterizedTest`, `@ValueSource` 를 사용해 매개변수화 테스트 기법을 사용한다.&#x20;

```java
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);
    }
}
```

```java
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. 누락된 테스트 시나리오 발견&#x20;

테스트 시나리오 목록을 작성한 후 시스템을 구현하는 과정에서 목록에 포함하지 못했던 시나리오가 발견되는 상황을 경험하자.&#x20;

### 새로 발견한 테스트 시나리오&#x20;

* 사용자이름 검사 논리를 구현하는 과정에서, `"^[a-z]*$"` 정규식은 처음 작성된 테스트 시나리오 목록 구현에는 충분한 코드이지만 사용자이름 정책을 충분하게 반영하지는 않는다는 것을 발견했습니다.
* 이 문제를 즉시 다루기 보다는 우선 관련된 새로운 테스트 시나리오를 목록에 추가해 놓고 현재 작업에 집중했습니다.

```java
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);
    }
}
```

```java
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();
        }
    }
}
```

```java
...

테스트
- [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. 올바른 입력 데이터 집합&#x20;

시스템 정책을 준수하는 여러 테스트 데이터를 사용해 테스트 시나리오를 구현하자.&#x20;

```java
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);
    }
}
```

```java
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. 관성을 따르는 코드 구현&#x20;

지금까지 작성했던 코드를 바탕으로 입력 데이터를 검사하는 테스트 시나리오를 관성에 따라서 구현해보자.&#x20;

```java
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);
    }
}
```

```java
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. 리팩토링&#x20;

### 리팩터링과 도구&#x20;

* 여러 번 중첩된 `else if` 문으로 인해 읽기 어려워진 구현 코드의 설계를 개선해 가독성을 높였다.&#x20;
* 리팩터링 과정에서 여러 차례 테스트를 실행하며 안정감을 유지했다.&#x20;
* 직접 코드를 수정하기 보다는 가능하면 도구가 제공하는 기능을 사용해 리팩터링 속도는 높이고 수작업의 실수는 최소화했다.&#x20;
* 리팩터링 후 테스트 시 문제 없다.&#x20;

```java
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;
    }
}
```
