Web/Spring 2020. 3. 1. 01:34

 

오늘 다루어볼 내용은 테스트 코드에서 DB관련된 테스트를 작성하는 방법이다. 사실 지금까지 여러가지 유닛테스트를 짜면서 로직에 대한 검증을 하긴 했지만, 데이터베이스와 관련된 테스트 작성은 조금 꺼려하기는 했다. 이유는 여러가지이지만, 귀찮은 설정들이 필요하고 사실 외부 환경에 따라 테스트가 실패할 가능성도 있기 때문이다. 하지만 계속 미룰수는 없는 법.. 오늘은 간단하게 데이터베이스관련 된 테스트를 Junit으로 만들어볼 것이다.

 

환경

  • springboot
  • mongodb
  • junit

 

다루어볼 예제는 정말 간단한 User 객체에 대한 생성,수정,조회 등을 테스트해볼 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
buildscript {
    ext {
        flapdoodleVersion = "2.2.0"
    }
}
 
plugins {
    id 'org.springframework.boot' version '2.2.5.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'
}
 
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '13'
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation "de.flapdoodle.embed:de.flapdoodle.embed.mongo:${flapdoodleVersion}"
-> 임베디드 몽고 의존
}
 
test {
    useJUnitPlatform()
}
 
cs

 

build.gradle 내용이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
public class User {
 
    @JsonIgnore
    private ObjectId id;
    @NotNull
    private String username;
    @NotNull
    private Integer age;
    @NotNull
    private Address address;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class Address {
        @NotNull
        private String si;
        @NotNull
        private String gu;
        @NotNull
        private String dong;
    }
}
cs

 

간단한 유저 도메인 객체이다.

 

1
2
3
4
5
public interface UserRepository {
    Mono<User> findUserById(final ObjectId id);
    Mono<User> updateUser(final User user);
    Mono<User> createUser(final User user);
}
cs

 

해당 인터페이스는 DB Access 구현 방식에 구애 받지 않고 core한 영역에서 사용하기 위한 User Data Access 인터페이스이다. 나머지 코드를 보면 왜 구현 코드에 구애 받지 않는지 알 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
@Document("User")
public class UserData {
 
    @Id
    private ObjectId id;
    private String name;
    private Integer age;
    private AddressData userAddress;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class AddressData {
        private String si;
        private String gu;
        private String dong;
 
        public User.Address from() {
            return User.Address.of(
                    si,
                    gu,
                    dong
            );
        }
 
        public static UserData.AddressData to(User.Address address) {
            return UserData.AddressData.of(
                    address.getSi(),
                    address.getGu(),
                    address.getDong()
            );
        }
    }
 
    public User from() {
        return User.of(
                id,
                name,
                age,
                userAddress.from(),
                hobby
        );
    }
 
    public static UserData to(User user) {
        return UserData.of(
                user.getId(),
                user.getUsername(),
                user.getAge(),
                AddressData.to(user.getAddress()),
                user.getHobby()
        );
    }
}
cs

 

실제 DB Access를 위한 엔티티 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
 
    private final UserMongoRepository repository;
 
    @Override
    public Mono<User> findUserById(final ObjectId id) {
        return repository.findById(id).map(UserData::from);
    }
 
    @Override
    public Mono<User> updateUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
    @Override
    public Mono<User> createUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
}
cs

 

실제 DB Access 구현체를 필드에 가지고 있는 UserRepository의 구현체이다. 즉, core(프레임워크에 영향을 받지 않는 비지니스 영역)한 영역에서 UserRepository를 사용할때는 구현체에 대한 자세한 정보가 필요하지 않고 DI해서 사용하면 되는 것이다. 

 

1
public interface UserMongoRepository extends ReactiveMongoRepository<UserData, ObjectId> { }
cs

 

UserRepositoryImpl가 가지고 있는 DB Access Object 구현체이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableReactiveMongoRepositories(basePackages = {"com.example.test.data.db.repositories"}, reactiveMongoTemplateRef = "userMongoTemplate")
public class MongoConfig {
 
    @Bean
    public MongoClient mongoClient() {
        return MongoClients.create("mongodb://localhost:27017");
    }
 
    @Bean
    public ReactiveMongoTemplate userMongoTemplate(MongoClient mongoClient) {
        return new ReactiveMongoTemplate(mongoClient, "USER_DB");
    }
 
}
cs

 

몽고디비 설정이다. 여기까지 실제 동작하는 코드 영역의 클래스들이다. 이러한 클래스들이 잘 동작하길 원할 것이고, 실제 테스트를 해볼 것이지만 효율적인 테스트 방법이 무엇일까? 관련 API를 만들어서 직접 호출해본다? 과연 좋은 테스트 방법인지는 모르겠다. 이러한 DB 관련 테스트 코드를 짜보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@TestConfiguration
@EnableReactiveMongoRepositories(basePackages = {
        "com.example.test.data.db.repositories"
}, reactiveMongoTemplateRef = "userTestMongoTemplate")
public class TestEmbeddedMongoConfig {
 
    @Bean(initMethod = "start", destroyMethod = "stop")
    public MongodExecutable mongodExecutable() throws IOException {
        MongodStarter starter = MongodStarter.getDefaultInstance();
 
        IMongodConfig mongoDConfig = new MongodConfigBuilder()
                .version(Version.Main.V3_4)
                .net(new Net("127.0.0.1"27017false))
                .build();
 
        return starter.prepare(mongoDConfig);
    }
 
    @Bean("userTestMongoTemplate")
    public ReactiveMongoTemplate mongoTemplate() throws IOException {
        MongoClient mongo = MongoClients.create("mongodb://localhost:27017");
        return new ReactiveMongoTemplate(mongo, "USER_TEST_DB");
    }
}
cs

 

해당 설정은 테스트 코드에서만 사용하기 위한 설정이다. 몽고디비는 임베디드 몽고를 사용할 것이다. 필자로 사실 내부적으로 정확히 어떻게 동작하는지는 자세히 보지 않았지만, 실제 특정 포트를 물고 프로세스로 뜬다. 그래서 직접 포트 번호, ip를 기입해준다. 설정들이 다양하지만 간단히 예제를 다루어보는 것임으로 간단하게 설정을 마친다.

 

다음은 실제 테스트 코드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@ContextConfiguration(
        initializers = ConfigFileApplicationContextInitializer.class,
        classes = {
                TestEmbeddedMongoConfig.class,
                UserRepositoryImpl.class
        })
@ExtendWith(SpringExtension.class)
public class UserRepositoryTest {
 
    @Autowired
    private UserRepository userRepository;
 
    private User initData;
 
    @BeforeEach
    public void setup() {
        initData = userRepository.createUser(
                User.of(
                        new ObjectId(),
                        "yeoseonggae",
                        29,
                        User.Address.of("seoul""kangbuk""mia"),
                        "none"
                )
        ).block();
    }
 
    @Test
    @DisplayName("유저 데이터 생성 테스트")
    public void createUserTest() {
        //준비
        User createUser = User.of(
                new ObjectId(),
                "UserName",
                29,
                User.Address.of("seoul""kangbuk""mia"),
                "none"
        );
 
        //실행
        Mono<User> createdUser = userRepository.createUser(createUser);
 
        //단언
        StepVerifier.create(createdUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "UserName");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 데이터 수정 테스트")
    public void updateUserTest() {
        //준비
        initData.setUsername("여성게");
 
        //실행
        Mono<User> updatedUser = userRepository.updateUser(initData);
 
        //단언
        StepVerifier.create(updatedUser)
                .assertNext(user -> {
                    assertEquals(user.getId().toHexString(), initData.getId().toHexString());
                    assertEquals(user.getUsername(), "여성게");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 이름으로 유저 데이터 조회 테스트")
    public void findUserByNameTest() {
        //준비
        final ObjectId id = initData.getId();
 
        //실행
        Mono<User> findUser = userRepository.findUserById(id);
 
        //단언
        StepVerifier.create(findUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "yeoseonggae");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
}
cs

 

바로 위에 있는 테스트 몽고 설정을 import 한다. 그리고 우리는 UserRepository를 테스트 할것임으로 모든 빈들을 가져올 필요가 없이 테스트에 필요한 빈만 주입받아서 테스트 코드를 작성한다. 과연 결과는 어떻게 될까?(위 코드에 조금더 추가하여 실제로 데이터가 어떻게 왔다갔다하는지 콘솔로 찍었다.)

 

우선 테스트 성공 여부이다.

 

 

크아 깔끔하게 테스트를 통과하였다. 그렇다면 진짜 데이터가 왔다갔다 한것이 맞을까? 콘솔로 찍힌 로그를 확인해보자 !

 

 

유저 데이터 수정 테스트 로그이다.

 

 

유저 데이터 생성 테스트 로그이다.

 

 

마지막으로 조회 테스트 로그이다. 실제로 데이터가 잘 찍혀있다. 여기까지 간단히 몽고디비를 이용한 DB Access unit 테스트를 다루어보았다. 정말 간단한 테스트이지만, 이 예제를 잘 이용하면 실제 서비스 클래스 영역까지 사용되어서 정말 내가 개발한 클래스가 정상 작동하는지 확인해볼 수 있다.

 

혹시 코드가 필요하다면 아래 깃헙를 참고하자.

 

 

yoonyeoseong/dbTest

Contribute to yoonyeoseong/dbTest development by creating an account on GitHub.

github.com

posted by 여성게
:
Web/Spring 2019. 10. 10. 00:22

 

오늘 포스팅할 내용은 간단하게 Springboot Test이다. 아주 자세하게 다루어보진 않지만, 유용하고 자주 사용하는 부분이다. 간단하게 유닛테스트하는 내용은 이전 포스팅을 참고하고, 이번 내용은 Springboot 환경에서의 테스트 내용을 다룰것이다.

 

TDD - 테스트 주도 개발(Test Driven Development)

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발..

coding-start.tistory.com

 

다음 코드는 Springboot에서 빈으로 등록된 객체들을 사용할수 있게하는 테스트이다. 즉, ApplicationContext를 띄우는 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
============application.properties============
test.property=test-property
==============================================
@Service
public class CommonService {
    @Autowired
    private CommonBean commonBean;
 
    public String print(){
        String result = commonBean.print();
        System.out.println(result);
        return result;
    }
}
@Component
public class CommonBean {
    @Value("${test.property}")
    private String testProperty;
    public String print(){
        System.out.println(testProperty);
        return testProperty;
    }
}
 
@RunWith(SpringRunner.class)
@SpringBootTest
public class TestPropertyTest {
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
    @Test
    public void print(){
        commonBean.print();
    }
}
 
cs

 

위 테스트는 성공적으로 통과할 것이다. 하지만 여기서 하나 문제점이 존재한다. 만약 아주 큰 애플리케이션이라 ApplicationContext 로드에 아주 긴 시간이 걸린다면 작은 테스트를 하나 수행할때마다, 긴 시간이 걸릴 것이다. 이러할때, 우리가 테스트에 필요한 빈만 띄울수 있다. 아래 코드를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class,
        CommonBean.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

2가지 방법이 존재한다. 첫번째 방법은 @ContextConfiguration 어노테이션을 이용하는 방법이다. 인자로 빈으로 띄울 클래스를 넘긴다. 여기서 중요한것을 관련된 클래스도 모두 넣어줘야한다는 것이다. 위를 예를 들면 CommonService 클래스를 빈으로 등록할 것인데, 해당 클래스의 내용을 보면 CommonBean을 DI받고 있으므로, CommonBean도 빈으로 띄워줘야하는 것이다. 나중에 다루어볼 것이지만 위와같이 빈으로 띄울 클래스를 명시적으로 등록해도되지만, 굳이 테스트에 사용하지 않으면 Mock 빈으로 등록해주어도 된다. 두번째 인자는 initializers이다. 만약 이 인자가 없다면, CommonBean에서 @Value로 DI 받고있는 프로퍼티값을 주입받지 못한다. 즉, application.properties의 내용을 읽지 못하는 것이다. 내부적으로 @Value를 받기위한 빈이 뜨지 못해서 그런 것 같다. 이와 같은 문제를 해결하기 위해서 initializers = ConfigFileApplicationContextInitializer.class를 등록해주므로써 프로퍼티값을 읽어 올 수 있게 하였다. 

 

두번째 방법은 아래와 같다. @SpringBootTest를 이용하여 더 간단한 설정으로 SpringBoot Test를 위한 설정을 할 수 있게된다. 또한 테스트에만 사용할 프로퍼티를 정의할 수도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {CommonBean.class,CommonService.class}
,properties = {"test.property=dev-property"})
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

두번째는 Mock빈을 이용하는 방법이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
 
    @Test
    public void mockBeanTest(){
        given(commonBean.print()).willReturn("Test Result");
        commonService.print();
    }
 
}
cs

 

CommonService의 비즈니스 로직을 테스트하고 싶은데, 굳이 CommonBean은 띄울 필요가 없을때, 위와 같이 MockBean을 등록해서 임의로 Mock 빈이 리턴할 값을 명시할 수 있다. 위 소스는 CommonService만 테스트를 위한 빈으로 띄우는데, 내부적으로 관련있는 CommonBean은 띄우지 않고 Mock 빈으로 띄운다. 그리고 CommonBean이 리턴하는 값을 임의로 "Test Result"라는 값으로 지정해주었다. 이렇게 테스트할 빈만 집중하여 테스트가 가능해지며 사이드 코드의 양을 줄일 수 있게 된다.

 

다음은 간단하게 Controller를 테스트할 수 있는 코드이다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RunWith(SpringRunner.class)
@WebMvcTest(controllers = CommonTestController.class)
@ContextConfiguration(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class}
        ,initializers = ConfigFileApplicationContextInitializer.class)
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

굳이 스프링을 실행시키지 않고도 컨트롤러를 테스트할 수 있다. @WebMvcTest 어노테이션을 이용하여 스프링 컨트롤러 테스트가 가능한 설정을 띄워준다. 즉, MVC관련 빈만 띄워 더욱 가볍게 스프링 컨트롤러 테스트가 가능하다. 위와 같은 어노테이션도 가능하며 아래와 같은 어노테이션 설정도 사용가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

@AutoConfigureMockMvc 어노테이션을 이용하여 MVC 테스트를 위한 설정을 AutoConfiguration하게 할수 있다. 물론, MVC테스트를 위하여 내부적으로 내/외부 연동코드가 있다면 내/외부연동을 하지 않기 위해 Mock 빈등을 이용하여 테스트 가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private CommonBean commonBean;
    @MockBean
    private CommonService commonService;
 
    @Test
    public void resultTest() throws Exception {
        given(commonBean.print()).willReturn("test");
        given(commonService.print()).willReturn("test");
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

마지막으로 테스트만을 위한 application.properties를 사용는 방법이다. 우선, application-test.properties를 classpath 밑의 resources 폴더 안에 넣어준다.(기존 application.properties 경로와 동일) 그리고 아래와 같이 @ActiveProfile 어노테이션을 이용하여 프로파일을 이용하여 읽어올 프로퍼티파일을 명시한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
@ActiveProfiles("test")
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

위 코드는 기존 application.properties가 아닌, application-test.properties 파일을 읽어 컨텍스트를 구성할 것이다.

 

아주 간단하게만 springboot test를 다루어봤는데, 추후 포스팅에서 지금까지 다룬 내용과 더 자세한 테스팅관련 기술을 다루어볼 것이다.

posted by 여성게
:
Web/TDD 2019. 9. 10. 12:59

 

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발(TDD, Test Driven Development)에 기반을 둔 단위 테스트 전략의 핵심이다.

 

TDD에서 단위 테스트를 시스템의 모양을 잡고 통제하는 도구로 활용해야 한다. 단위 테스트는 종종 잘 선별한 후 한쪽에 치워 놓고 나중에 반영하려는 코드가 될 수 있는데, 단위 테스트는 소프트웨어를 어떻게 만들어야 할지에 관한 잘 훈련된 사이클의 핵심적인 부분이다. 따라서 TDD를 채택하면 소프트웨어 설계는 달라지고, 아마 훨씬 더 좋은 설계의 코드가 될 것이다.

 

TDD의 주된 이익

단위 테스트를 사후에 작성하여 얻을 수 있는 가장 분명하고 명확한 이익은 "코드가 예상한 대로 동작한다는 자신감을 얻는 것" 이다. TDD에서도 역시 동일한 이익과 그 이상을 얻을 수 있다. 

 

코드를 깨끗하게 유지하도록 치열하게 싸우지 않으면 시스템은 점점 퇴화한다. 코드를 재빠르게 추가할 수는 있지만 처음에는 좋은 코드라기보다는 그다지 위대하지 않은 코드일 가능성이 높다. 보통은 개발 초기부터 나쁜 코드를 여러 가지 이유로 정리하지 않고는 한다.

 

TDD에서는 코드가 변경될 것이라는 두려움을 지울 수 있다. 정말로 리팩토링은 위험 부담이 있는 일이기도 하고 우리는 위험해보이지 않는 코드를 변경할 때도 실수를 하곤 한다. 하지만 TDD를 잘 따른다면 구현하는 실질적인 모든 사례에 대해 단위 테스트를 작성하게 된다. 이러한 단위 테스트는 코드를 지속적으로 발전시킬 수 있는 자유를 준다.

 

TDD는 세 부분의 사이클로 구성된다.

  • 실패하는 테스트 코드 작성하기
  • 테스트 통과하기
  • 이전 두 단계에서 추가되거나 변경된 코드 개선하기

첫 번째 단계는 시스템에 추가하고자 하는 동작을 정의하는 테스트 코드를 작성하는 것이다.

 

우리는 이미 이전 포스팅에서 이미 다루어봤던 Profile이라는 클래스를 새로 만들 것이다. 가장 단순한 사례로, Profile 클래스 자체를 만들기 전에 먼저 테스트 코드를 아래와 같이 작성해보자.

 

1
2
3
4
5
6
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      new Profile();
   }
}
cs

 

Profile이라는 클래스가 존재하지 않기 때문에 컴파일 에러가 발생할 것이다. 또한 IDEA는 Profile이라는 클래스가 없으므로 클래스를 생성해달라 할 것이다. Profile 클래스를 Quick Fix 기능으로 생성하면 컴파일에러는 해결될 것이다. 사실 이러한 작은 테스트는 컴파일되는 것만으로 충분한 테스트가 되기 때문에 굳이 테스트를 실행시킬 필요는 없다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}
 
public class Profile {
   public boolean matches(Criterion criterion) {
      return true;
   }
}
cs

 

컴파일 에러를 해결하며 위와 같이 작은 테스트 단위로 하나씩 코드를 증가시켜가며 작성해준다. 여기까지 메서드 내부를 세부적으로 구현하지 않고 단순히 true를 리턴하는 메서드를 작성하므로써 실패하는 테스트 코드를 작성하였다. 그리고 테스트 성공을 위해 assertTrue(result)로 변경하여 테스트를 성공시킨다.

 

여기까지 우리는 Profile 클래스의 한 작은 부분을 만들었고 그것이 동작함을 알게되었다. 여기까지 소스를 작성하였다면 Git과 같은 VCS에 커밋할 차례이다. TDD를 하면서 작은 코드를 커밋하는 것은 필요할 때 백업하거나 방향을 반대로 돌리기 수월해진다. 만약 큼지막한 단위로 커밋한다면 롤백은 그만큼 힘든 작업이 된다.

 

다음 작성할 테스트 코드는 Profile이 갖고 있는 Answer 객체와 Criterion이 가지고 있는 Answer 객체를 매칭시키는 테스트이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ProfileTest {
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Criterion criterion = 
         new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      Profile profile = new Profile();
      Question question = new BooleanQuestion(1"Relocation package?");
      Answer answer = new Answer(question, Bool.TRUE);
      profile.add(answer);
      Criterion criterion = new Criterion(answer, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}
cs

 

위와 같이 profile.matches()를 테스트 코드로 추가한다. 하지만 matches()는 세부적인 구현이 이루어지지 않은 상태이므로 아래와 같이 Profile에 matches()를 수정한다.

 

1
2
3
4
5
6
7
8
9
10
11
public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null;
   }
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}
cs

 

이제 Profile이 Answer 객체만 가지고 있다면 true를 리턴할 것이다.

 

여기까지 테스트 코드를 작성하였다면 이제는 테스트 코드를 조금은 정리할 필요가 있습니다. 두 개의 테스트만 보더라도 중복되는 코드 라인이 보이기 때문에 @Before 메서드로 중복된 초기화 코드를 분리해줍니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
}
cs

 

테스트 코드의 리팩토링 과정에서 프로덕 코드의 변경은 없었고 물론 테스트도 통과할 것이다. 하지만 분명한 필드이름과 중복된 코드의 제거로 더욱 깔끔한 테스트코드가 되었다.

 

여기서 자칫하면 "어? 여러개의 @Test 메서드가 동일한 필드들을 공유하는데, 테스트 결과가 잘못 나오는 거 아니야?"라고 질문을 던질 수 있다. 하지만 이전 포스팅에서 얘기 햇듯이 @Test 마다 새로운 ProfileTest 인스턴스를 생성하기 때문에 매 테스트마다 인스턴스 변수는 독립적으로 사용한다.

 

다음 테스트는 Profile 인스턴스가 매칭되는 Answer 객체가 없을 때, matches() 메서드가 false를 반환하는 테스트이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class ProfileTest {
   private Answer answerThereIsNotRelocation;
   // ... 
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionAndAnswer() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
   }
   // ...
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
}
cs

 

테스트가 통과하려면 matches() 메서드는 Profile 객체가 들고 있는 단일 Answer 객체가 Criterion 객체에 저장된 응답과 매칭되는 지 결정해야 한다. Answer 클래스를 보면 어떻게 응답들을 비교하는지 알 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Answer {
   // ...
   private int i;
   private Question question;
 
   public Answer(Question question, int i) {
      this.question = question;
      this.i = i;
   }
 
   public Answer(Question question, String matchingValue) {
      this.question = question;
      this.i = question.indexOf(matchingValue);
   }
   
   public String getQuestionText() {
      return question.getText();
   }
 
   @Override
   public String toString() {
      return String.format("%s %s"
         question.getText(), question.getAnswerChoice(i));
   }
 
   public boolean match(int expected) {
      return question.match(expected, i);
   }
 
   public boolean match(Answer otherAnswer) {
      // ...
      return question.match(i, otherAnswer.i);
   }
   // ...
 
   public Question getQuestion() {
      return question;
   }
}
cs

 

이제 match 메서드를 이용하여 테스트를 통과하는 matches() 메서드 내의 단일 조건문을 추가한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Profile {
   private Answer answer;
 
   public boolean matches(Criterion criterion) {
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
   // ...
 
   public void add(Answer answer) {
      this.answer = answer;
   }
}
cs

 

TDD로 성공하러면 테스트 시나리오를 테스트로 만들고 각 테스트를 통과하게 만드는 코드 증분을 최소화하는 순으로 코드를 작성한다.

 

이제 Profile 클래스가 다수의 Answer 객체를 갖도록 수정할 것이다. 다수의 Answer 객체를 Profile 클래스는 Map으로 갖도록 설계하였다.(key,value)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
   }
 
   @Test
   public void matchesNothingWhenProfileEmpty() {
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.DontCare);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
  
   @Test
   public void matchesWhenProfileContainsMatchingAnswer() {
      profile.add(answerThereIsRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
 
      boolean result = profile.matches(criterion);
 
      assertTrue(result);
   }
   
   @Test
   public void doesNotMatchWhenNoMatchingAnswer() {
      profile.add(answerThereIsNotRelocation);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertFalse(result);
   }
 
   @Test
   public void matchesWhenContainsMultipleAnswers() {
      profile.add(answerThereIsRelocation);
      profile.add(answerDoesNotReimburseTuition);
      Criterion criterion = 
            new Criterion(answerThereIsRelocation, Weight.Important);
      
      boolean result = profile.matches(criterion);
      
      assertTrue(result);
   }
}
cs

 

먼저 Profile이 다수의 Answer 객체를 가지는 테스트 코드를 작성한다.(matchesWhenContainsMultipleAnswers)

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return answer != null && 
         answer.match(criterion.getAnswer());
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

테스트 통과를 위한 Profile 클래스를 수정한다. 테스트 통과를 위해 matches() 메서드의 일부로 getMatchingProfileAnswer() 메서드를 호출하여 반환값이 null인지 여부를 확인한다. 하지만 이러한 널 체크 구문을 다른 곳으로 숨기고 싶다. 그래서 Answer 클래스의 match() 메서드로 널체크를 보낼 것이다.

 

그렇다면 이전에 matches의 리턴문이 answer.match(criterion.getAnswer()) 였다면 아래와 같이 코드는 수정될 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

또한 Answer의 match()에는 null을 체크하는 구문이 추가된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Answer {
   private int i;
   private Question question;
 
   ...
 
   public boolean match(Answer otherAnswer) {
      if (otherAnswer == nullreturn false;
      // ...
      return question.match(i, otherAnswer.i);
   }
 
   ...
}
cs

 

하지만 여기서 끝이 아니다. 바로 위의 Answer를 수정하기 전에 아래와 같은 테스트코드가 작성되어야한다.

 

1
2
3
4
5
6
7
public class AnswerTest {
   @Test
   public void matchAgainstNullAnswerReturnsFalse() {
      assertFalse(new Answer(new BooleanQuestion(0""), Bool.TRUE)
        .match(null));
   }
}
cs

 

TDD를 할 때 다른 코드를 전혀 건드리지 않고 Profile 클래스만 변경할 필요는 없다. 필요한 사항이 있다면 설계를 변경하여 다른 클래스로 너어가도 된다.(Answer 클래스)

 

이제는 조금 코드를 확장할 것이다. 단일 Criterion 객체로만 매칭하는 것이 아니라 다수의 Criterion 객체를 가지는 Criteria 객체를 인수로 받아 매칭하는 코드로 변화 시킬 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class ProfileTest {
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   @Test
   public void doesNotMatchWhenNoneOfMultipleCriteriaMatch() {
      profile.add(answerDoesNotReimburseTuition);
      Criteria criteria = new Criteria();
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.Important));
      
      boolean result = profile.matches(criteria);
      
      assertFalse(result);
   }
}
cs

 

이제는 Profile 객체가 Criteria 객체를 받아 매칭하는 테스트 코드를 작성하였다. 결과를 통과 시키기 위해서 Profile 클래스에 Criteria를 인자로 받는 하드 코딩한 메서드를 추가한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

그리고 빠르게 다음 테스트를 작성한다. 단순히 true를 리턴하는 메서드에서 Criteria 객체를 순회하며 하나씩 꺼낸 Criterion 객체를 매치하는 메서드로 수정한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
   public boolean matches(Criteria criteria) {
      for (Criterion criterion: criteria)
         if (matches(criterion))
            return true;
      return false;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

여기서 Criteria 객체를 로컬 변수로 매번 새롭게 생성하는데, 이것을 @Before 메서드를 활용하여 초기화하는 코드로 변경하면 더 깔끔한 코드가 된다.

 

이제 이 단계에서 여러 특별한 사례를 추가한다. Criterion의 MustMatch에 해당 되는 질문이 Profile 객체가 가지고 있지 않다면 실패하는 테스트 코드를 작성한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class ProfileTest {
   // ...
   private Profile profile;
   private BooleanQuestion questionIsThereRelocation;
   private Answer answerThereIsRelocation;
   private Answer answerThereIsNotRelocation;
   private BooleanQuestion questionReimbursesTuition;
   private Answer answerDoesNotReimburseTuition;
   private Answer answerReimbursesTuition;
   private Criteria criteria;
   
   @Before
   public void createCriteria() {
      criteria = new Criteria();
   }
   // ...
 
   @Before
   public void createProfile() {
      profile = new Profile();
   }
   
   @Before
   public void createQuestionsAndAnswers() {
      questionIsThereRelocation = 
            new BooleanQuestion(1"Relocation package?");
      answerThereIsRelocation = 
            new Answer(questionIsThereRelocation, Bool.TRUE);
      answerThereIsNotRelocation = 
            new Answer(questionIsThereRelocation, Bool.FALSE);
 
      questionReimbursesTuition = new BooleanQuestion(1"Reimburses tuition?");
      answerDoesNotReimburseTuition = 
         new Answer(questionReimbursesTuition, Bool.FALSE);
      answerReimbursesTuition = 
         new Answer(questionReimbursesTuition, Bool.TRUE);
   }
 
   ...
 
   
   @Test
   public void doesNotMatchWhenAnyMustMeetCriteriaNotMet() {
      profile.add(answerThereIsRelo);
      profile.add(answerDoesNotReimburseTuition);
      criteria.add(new Criterion(answerThereIsRelo, Weight.Important));
      criteria.add(new Criterion(answerReimbursesTuition, Weight.MustMatch));
      
      assertFalse(profile.matches(criteria));
   }
}
 
public class Profile {
   private Map<String,Answer> answers = new HashMap<>();
   
   private Answer getMatchingProfileAnswer(Criterion criterion) {
      return answers.get(criterion.getAnswer().getQuestionText());
   }
 
 
   public boolean matches(Criteria criteria) {
      boolean matches = false;
      for (Criterion criterion: criteria) {
         if (matches(criterion))
            matches = true;
         else if (criterion.getWeight() == Weight.MustMatch)
            return false;
      }
      return matches;
   }
 
   public boolean matches(Criterion criterion) {
      Answer answer = getMatchingProfileAnswer(criterion);
      return criterion.getAnswer().match(answer);
   }
 
   public void add(Answer answer) {
      answers.put(answer.getQuestionText(), answer);
   }
}
cs

 

테스트를 통과시키기 위해 Profile matches 메서드로 들어오고 Profile 객체의 Answer 중 매치되지 않은 질문의 weight이 MustMatch라면 false를 반환하는 코드로 작성한다.(즉, MustMatch는 반드시 매칭되어야 하는 필수조건인 것이다. 나머지가 다 맞고 MustMatch 하나만 안맞아도 매치는 false를 날린다.)

 

이런식으로 TDD를 이용하여 코드를 작성한다. 마지막으로 코드가 완성되었다면 테스트 클래스의 메서드의 이름들은 어떠한 코드의 명세서가 될 수 있다. 오늘 다루어본 TDD는 사실 두서없이 기본만 다루어본 내용이다. 사실 필자로 TDD에 익숙치 않은 개발자이기 때문에 TDD가 무엇인가 정도만 습득했어도 성공이라고 생각했다. 추후에는 실제 웹개발 코드를 작성할때 TDD를 이용한 개발 등의 포스팅을 할 예정이다.

 

사실 노력없는 결실은 없는 것 같다. 꾸준히 TDD로 개발하다보면 언젠간 적응되고 더 좋은 코드를 개발하는 날이 오지 않을까?

 

앞으로 더욱 많은 TDD 관련 포스팅을 할 예정이며, 현재까지 작성된 포스팅은 아래 책을 기반으로 작성하였다.

 

posted by 여성게
:
Web/TDD 2019. 9. 10. 11:26

 

단위 테스트, 혹은 여느 테스트 코드를 작성하는 일은 상당한 투자와 비용이 드는 작업이다. 하지만 테스트는 프로덕 코드의 결함을 최소화하고 리팩토링으로 프로덕 시스템을 깔끔하게 유지시켜준다. 그렇지만 역시 지속적인 비용을 의미하는 것은 부정할 수 없다. 시스템이 변경됨에 따라 테스트 코드도 다시 들여다보아야한다. 때때로 변경 사항들을 생겨나고 그 결과로 수많은 테스트 케이스가 깨져 테스트 코드를 수정해야 한다.

 

그렇다면 만약 테스크 코드가 굉장히 복잡하고 지저분하다면 어떻게 될까? 새로운 변경사항이 생겨날 때마다 테스트 코드를 수정하는 일은 더욱 더 힘든 일이 될 것이다. 그래서 이번 포스팅에서는 테스트 코드를 리팩토링하여 유지보수가 쉬운 테스트 코드를 만드는 것을 간단하게 소개할 것이다. 이번 포스팅의 목표는 리팩토링의 대상이 프로덕 코드만이 아니라 테스트 코드도 되어야한다라는 것을 알기 위한 글이다.

 

검색 기능에 대한 테스트 코드가 아래와 같이 있다. 하지만 해당 테스트 코드를 보면 이 테스트 코드가 어떠한 것을 검증하고 증명하려하는지 한눈에 파악하기 힘든 코드이다. 더군다나 우리는 Search 하는 클래스가 정확히 어떤 일을 하는 지도 모른다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class SearchTest {
   @Test
   public void testSearch() {
      try {
        String pageContent = "There are certain queer times and occasions "
              + "in this strange mixed affair we call life when a man "
              + "takes this whole universe for a vast practical joke, "
              + "though the wit thereof he but dimly discerns, and more "
              + "than suspects that the joke is at nobody's expense but "
              + "his own.";
         byte[] bytes = pageContent.getBytes();
         ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
         // search
         Search search = new Search(stream, "practical joke""1");
         Search.LOGGER.setLevel(Level.OFF);
         search.setSurroundingCharacterCount(10);
         search.execute();
         assertFalse(search.errored());
         List<Match> matches = search.getMatches();
         assertThat(matches, is(notNullValue()));
         assertTrue(matches.size() >= 1);
         Match match = matches.get(0);
         assertThat(match.searchString, equalTo("practical joke"));
         assertThat(match.surroundingContext, 
               equalTo("or a vast practical joke, though t"));
         stream.close();
 
         // negative
         URLConnection connection = 
               new URL("http://bit.ly/15sYPA7").openConnection();
         InputStream inputStream = connection.getInputStream();
         search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
         search.execute();
         assertThat(search.getMatches().size(), equalTo(0));
         stream.close();
      } catch (Exception e) {
         e.printStackTrace();
         fail("exception thrown in test" + e.getMessage());
      }
   }
}
cs

 

이 테스트 코드의 문제점 몇 가지를 찾아보자.

  • 테스트 이름인 testSearch는 어떤 유용한 정보도 제공하지 못한다.
  • 몇 줄의 주석은 우리에게 어떠한 도움도 주지 못한다.
  • 다수의 단언(assert)가 존재해 무엇을 증명하고 검증하려고 하는지 이해하지 못한다.

더 많은 냄새가 나지만 일단 위와 같은 문제점들이 딱 눈에 보인다. 우리는 이 코드를 깔끔하게 리팩토링 해볼 것이다.

 

1)불필요한 테스트 코드

위 테스트 코드에 크게 감싸고 있는 try/catch 구문은 사실 크게 이점이 없는 코드 블록이다. 현재 테스트 코드에서 해당 블록이 하는 역할은 예외가 발생하면 스택 트레이서를 출력하고 테스트를 실패시키며 예외 메시지를 뿌려주는 역할을 한다. 하지만 사실 try/catch 구문이 없더라도 JUnit은 발생한 예외를 잡아 스택 트레이서를 뿌려주고 테스트에 오류가 발생함을 알려주기 때문에 위의 try/catch는 불필요하다.(무조건 테스트에서 try/catch구문이 필요없다는 것이 아니다.)

 

두번째는 20Line에 있는 notNullValue() 부분이다. 바로 21Line에는 matchs의 사이즈가 1보다 크거나 같음을 이미 테스트하고 있다. 또한 널을 체크하는 구문이 없더라도 만약 matches가 널이라면 21Line에서 테스트 실패가 날것이므로 굳이 널 체크 단언을 넣을 필요가 없다. 물론 프로덕 코드에서는 널 체크하는 구문이 아주 큰 역할을 할 수는 있다. 하지만 이 테스크 코드에서는 크게 이점이 없는 코드구문이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      // ...
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke""1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      List<Match> matches = search.getMatches();
      assertTrue(matches.size() >= 1);
      Match match = matches.get(0);
      assertThat(match.searchString, equalTo("practical joke"));
      assertThat(match.surroundingContext, equalTo(
            "or a vast practical joke, though t"));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
      search.execute();
      assertThat(search.getMatches().size(), equalTo(0));
      stream.close();
   }
}
cs

 

위의 문제점을 고쳐 위와 같은 테스트 코드로 리팩토링하였다. 하지만 여전히 지저분해보이는 코드이다.

 

2)추상화 누락

위 코드에서는 다수의 단언(assert)가 존재한다. 이 중에서 20,22,23Line의 단언은 사실 하나의 개념을 구체화하고 있는 단언이다. 하나의 개념을 구체화하고 있다는 뜻은 하나의 assert 문으로도 충분히 테스트할 수 있는 케이스라는 뜻이다. 우리는 여기에서 사용자 정의 매처를 생성하여 위의 3개의 단언문을 하나의 단언으로 리팩토링할 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class ContainsMatches extends TypeSafeMatcher<List<Match>> {
   private Match[] expected;
 
   public ContainsMatches(Match[] expected) {
      this.expected = expected;
   }
 
   @Override
   public void describeTo(Description description) {
      description.appendText("<" + expected.toString() + ">");
   }
 
   private boolean equals(Match expected, Match actual) {
      return expected.searchString.equals(actual.searchString)
         && expected.surroundingContext.equals(actual.surroundingContext);
   }
 
   @Override
   protected boolean matchesSafely(List<Match> actual) {
      if (actual.size() != expected.length)
         return false;
      for (int i = 0; i < expected.length; i++)
         if (!equals(expected[i], actual.get(i)))
            return false;
      return true;
   }
 
   @Factory
   public static <T> Matcher<List<Match>> containsMatches(Match[] expected) {
      return new ContainsMatches(expected);
   }
}
cs

 

위 코드와 같이 사용자 정의 매처를 생성한다. 사용자 정의 매처는 햄크레스트의 TypeSafeMatcher<T>를 상속하여 구현할 수 있다. 첫번째 오버라이드하는 메서드 describeTo는 테스트 실패시 우리가 기대한 값을 출력하기 위한 메서드이다. 두번째 matchesSafely는 실제 expected값과 actual 값 비교를 위한 메서드이다. 마지막 containsMatches는 JUnit 코드에서 사용되는 팩토리 메소드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SearchTest {
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            // ...
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke""1");
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1""practical joke"
                   "or a vast practical joke, though t") }));
      stream.close();
      // ...
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt""http://bit.ly/15sYPA7");
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}
cs

 

우리는 사용자 정의 매처를 만들어서 위와 같이 3개의 assert를 하나의 assert 구문으로 바꾸는 마법을 부릴 수 있게 되었다. 한편으로는 "테스트 몇 줄을 줄이기 위해 사용자 정의 매처를 만드는 수고를 해야되?"라고 생각할 수는 있지만 이 사용자 정의 매처는 여러 테스트 코드에서 재활용될 수 있기에 충분한 가치가 있다.

 

그리고 마지막의 단언이였던 search.getMatches의 크기가 equalsTo(0)인가라는 단언은 위와 같이 .isEmpty()로 바꾸어서 조금 더 사실적인 정보를 줄 수 있다.

 

3)부절절한 정보

잘 추상화된 테스트는 코드를 이해하는 데 중요한 것을 부각시켜 주고 그렇지 않은 것은 보이지 않게 해준다. 테스트에 사용되는 데이터는 어떠한 테스트 시나리오를 설명할 수 있게 도움을 주어야 한다.

 

때때로 테스트에는 부적절하지만, 당장 컴파일 에러를 피하기 위해 데이터를 넣기도 한다. 예를 들어 메서드가 테스트에는 어떤 영향도 없는 부가적인 인수를 취하기도 한다.

 

테스트는 그 의미가 불분명한 "매직 리터럴"들을 포함하고 있다.

매직 리터럴 - 프로그래밍에서 상수로 선언하지 않은 숫자 리터럴을 "매직 넘버"라고 하며, 코드에는 되도록 사용하면 안된다.

 

1
2
3
4
5
6
7
8
9
...
 
Search search = new Search(stream, "practical joke", "1");
 
assertThat(search.getMatches(), containsMatches(new Match[] { 
         new Match("1", "practical joke", 
                   "or a vast practical joke, though t") }));
 
...
cs

 

위 코드를 보면 상수 "1"이 무슨 역할을 하는지 확신할 수 없다. 따라서 그 의미를 파악하기 위해서는 Search와 Match 클래스를 까봐야한다.(실제 코드 내부적으로는 "1"이 검색 제목을 의미하며 실제로 검색에 사용되지 않는 필드 값이다.)

 

"1"을 포함한 매직 리터럴은 불필요한 질문을 유발한다. 또한 이 값이 테스트에서 어떠한 영향을 미치는 지 소스를 파느라 시간을 낭비하게 될 것이다.

 

1
2
3
4
URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", "http://bit.ly/15sYPA7");
cs

 

또한 위의 URL 값은 어떻게 보면 위의 URL과 연관성이 있어 보이지만, 실제로는 무관한 값이다. 이렇게 "1"과 URL 값 같이 의미가 불분명하거나 혼란스러운 상황을 유발하는 리터럴 같은 경우는 의미파악이 쉬운 상수로 대체하면 의미를 분명히 전달할 수 있게 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class SearchTest {
   private static final String A_TITLE = "1";
   @Test
   public void testSearch() throws IOException {
      String pageContent = "There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.";
      byte[] bytes = pageContent.getBytes();
      ByteArrayInputStream stream = new ByteArrayInputStream(bytes);
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[] 
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
}
cs

 

혹은 빈 문자열 등을 인자로 넘겨 테스트와는 무관한 값임을 표현하는 것도 하나의 방법이 될 수 있다.

 

4)부푼 생성

테스트 코드를 보면 Search 생성자에 InputStream 객체를 넘기고 있다. 또한 이 InputStream를 만들기 위해서 3개의 라인을 차지 하고 있다. 이러한 생성 관련된 코드를 하나의 메서드로 분리하면 여러 코드에서도 재활용가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void testSearch() throws IOException {
      InputStream stream =
            streamOn("There are certain queer times and occasions "
             + "in this strange mixed affair we call life when a man "
             + "takes this whole universe for a vast practical joke, "
             + "though the wit thereof he but dimly discerns, and more "
             + "than suspects that the joke is at nobody's expense but "
             + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      // ...
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke",
                              "or a vast practical joke, though t") }));
      stream.close();
 
      // negative
      URLConnection connection =
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      stream.close();
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

5)다수의 단언문(assert)

하나의 단위 테스트는 하나의 단언문으로 가는 것이 좋다. 때때로 단일 테스트에 다수의 단언문이 필요하긴 하지만 너무 많은 단언문을 가진다면 테스트 케이스를 두 개 이상을 포함하고 있는지 의심해봐야한다.

 

위 테스트에서는 어떠한 입력값에 대한 테스트와 어떠한 매칭도 되지 않는 테스트를 하나의 메서드 내에 작성하였음으로 두 개의 테스트로 분리 가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class SearchTest {
   private static final String A_TITLE = "1";
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() 
         throws IOException {
      InputStream stream = 
            streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      // search
      Search search = new Search(stream, "practical joke", A_TITLE);
      Search.LOGGER.setLevel(Level.OFF);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertFalse(search.errored());
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
      stream.close();
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      InputStream inputStream = connection.getInputStream();
      Search search = new Search(inputStream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
      inputStream.close();
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

6)테스트와 무관한 세부 사항들

위에서는 테스트와 부관한 로그를 끄는 코드, 스트림을 사용 후에 닫은 코드등 종단 관심이 아닌 횡단 관심에 해당되는 코드가 산재되어 있다. 이러한 코드는 @Before,@After 등과 같은 메서드로 분리가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
      search.execute();
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
      search.execute();
      assertTrue(search.getMatches().isEmpty());
   }
   // ...
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

7)잘못된 조직

테스트에서 어느 부분들이 준비(Arrange), 실행(Act), 단언(Assert) 부분인지 아는 것은 테스트를 빠르게 인지할 수 있게 한다. 이 조직은 "AAA"조직이라 불린다. 보통 이러한 블럭은 개행으로 분리한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("There are certain queer times and occasions "
            + "in this strange mixed affair we call life when a man "
            + "takes this whole universe for a vast practical joke, "
            + "though the wit thereof he but dimly discerns, and more "
            + "than suspects that the joke is at nobody's expense but "
            + "his own.");
      Search search = new Search(stream, "practical joke", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, "practical joke"
                              "or a vast practical joke, though t") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() 
         throws MalformedURLException, IOException {
      URLConnection connection = 
            new URL("http://bit.ly/15sYPA7").openConnection();
      stream = connection.getInputStream();
      Search search = new Search(stream, "smelt", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
 
cs

 

 

TDD - JUnit 테스트 코드 구성 방법.(테스트 코드 조직)

이번 포스팅에서 다루어볼 내용은 테스트 코드를 잘 조직하고 구조화 할 수 있는 JUnit 기능을 살펴볼 것이다. 포스팅에서 다룰 내용은 아래와 같다. 준비-실행-단언을 사용하여 테스트를 가시적이고 일관성 있게..

coding-start.tistory.com

 

8)암시적 의미

각 테스트가 분명하게 대답해야 할 가장 큰 질문은 "왜 그러한 결과를 기대하는 가?"이다. 테스트 코드를 보는 누군가에게 테스트 준비와 단언 부분을 상호 연관 지을 수 있게 해야한다. 단언이 기대하는 이유가 분명하지 않다면 코드를 읽는 사람들은 그 해답을 얻기 위해 다른 코드를 뒤져 가며 시간을 낭비할 것이다.

 

returnsMatchesShowingContextWhenSearchStringInContent 테스트는 이름만 딱 봐도 특정 컨텐츠 안에 특정 문자열이 포함되있다면 컨텍스트를 가지는 Matches를 리턴한다라는 것을 파악할 수 있다. 하지만 단언의 결과는 테스트 코드를 보는 사람들로 하여금 이해하기 힘들며 직접 하나하나 따져봐야하는 결과이다.("or a vast practical joke, though t") 

 

우리는 의미없는 문장을 넣어 이해하기 쉬운 기대 결과값을 만들어 낼 수 있다. 또한 URLConnection은 비용이 어느정도 있는 객체이기 때문에 굳이 사용할 필요가 없이, 외부환경과 분리해서 임의의 텍스트를 넣어 초기화하였다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class SearchTest {
   private static final String A_TITLE = "1";
   private InputStream stream;
   
   @Before
   public void turnOffLogging() {
      Search.LOGGER.setLevel(Level.OFF);
   }
   
   @After
   public void closeResources() throws IOException {
      stream.close();
   }
 
   @Test
   public void returnsMatchesShowingContextWhenSearchStringInContent() {
      stream = streamOn("rest of text here"
            + "1234567890search term1234567890"
            + "more rest of text");
      Search search = new Search(stream, "search term", A_TITLE);
      search.setSurroundingCharacterCount(10);
 
      search.execute();
 
      assertThat(search.getMatches(), containsMatches(new Match[]
         { new Match(A_TITLE, 
                    "search term"
                    "1234567890search term1234567890") }));
   }
 
   @Test
   public void noMatchesReturnedWhenSearchStringNotInContent() {
      stream = streamOn("any text");
      Search search = new Search(stream, "text that doesn't match", A_TITLE);
 
      search.execute();
 
      assertTrue(search.getMatches().isEmpty());
   }
 
   private InputStream streamOn(String pageContent) {
      return new ByteArrayInputStream(pageContent.getBytes());
   }
}
cs

 

여기까지 간단히 테스트 코드에 대한 리팩토링을 다루어봤다. 사실 리팩토링 능력은 경험의 차이가 아주 큰 것 같다. 테스트 코드를 짜는 습관을 들이다 보면 나도 리팩토링을 잘하는 날이 오겠지..

posted by 여성게
:
Web/TDD 2019. 9. 5. 13:25

 

단위 테스트를 작성하는데 있어서 FIRST 속성을 지킨다면 더 좋은 단위테스트를 작성할 수 있다. 그렇다면 FIRST 속성이란 무엇일까?

 

  • Fast : 빠른
  • Isolated : 고립된
  • Repeatable : 반복 가능한
  • Self-validating : 스스로 검증 가능한
  • Timely : 적시의

일반적인 단위 테스트를 작성하던 TDD 개발 방법론을 이용하던 FIRST 원리를 고수하면 더 나은 테스트 코드를 작성하는 데 도움이 된다.

 

1)Fast : 빠르게

테스트 코드를 느린 로직에 의존하지 않고 테스트를 빠르게 유지한다. 조금더 클린한 객체지향 설계에 맞춰 애플리케이션이 설계된다면 조금더 나은 테스트 코드를 작성하기 좋아진다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class StatCompiler {
   static Question q1 = new BooleanQuestion("Tuition reimbursement?");
   static Question q2 = new BooleanQuestion("Relocation package?");
 
   class QuestionController {
      Question find(int id) {
         if (id == 1)
            return q1;
         else
            return q2;
      }
   }
 
   private QuestionController controller = new QuestionController();
 
   public Map<String, Map<Boolean, AtomicInteger>> responsesByQuestion(
         List<BooleanAnswer> answers) {
      Map<Integer, Map<Boolean, AtomicInteger>> responses = new HashMap<>();
      answers.stream().forEach(answer -> incrementHistogram(responses, answer));
      return convertHistogramIdsToText(responses);
   }
 
   private Map<String, Map<Boolean, AtomicInteger>> convertHistogramIdsToText(
         Map<Integer, Map<Boolean, AtomicInteger>> responses) {
      Map<String, Map<Boolean, AtomicInteger>> textResponses = new HashMap<>();
      responses.keySet().stream().forEach(id -> 
         textResponses.put(controller.find(id).getText(), responses.get(id)));
      return textResponses;
   }
 
   private void incrementHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, 
         BooleanAnswer answer) {
      Map<Boolean, AtomicInteger> histogram = 
            getHistogram(responses, answer.getQuestionId());
      histogram.get(Boolean.valueOf(answer.getValue())).getAndIncrement();
   }
 
   private Map<Boolean, AtomicInteger> getHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, int id) {
      Map<Boolean, AtomicInteger> histogram = null;
      if (responses.containsKey(id)) 
         histogram = responses.get(id);
      else {
         histogram = createNewHistogram();
         responses.put(id, histogram);
      }
      return histogram;
   }
 
   private Map<Boolean, AtomicInteger> createNewHistogram() {
      Map<Boolean, AtomicInteger> histogram;
      histogram = new HashMap<>();
      histogram.put(Boolean.FALSE, new AtomicInteger(0));
      histogram.put(Boolean.TRUE, new AtomicInteger(0));
      return histogram;
   }
}
cs

 

위와 같은 코드가 있고 우리는 responsesByQuestion() 이라는 어떠한 메소드(행위)를 테스트 한다고 가정해보자. 해당 메서드는 다른 메서드를 다수 호출하고 있는 어떠한 행위이다. 그중 마지막 convertHistogramIdsToText()를 호출하고 있는데, 해당 메서드는 controller.find(id)로 데이터베이스를 액세스해서 id값에 해당되는 question text 값을 가져오고 있다. 만약 테스트할 개수가 아주 많다면 그만큼 데이터 액세스하는 속도가 줄어버릴 것이다. 그리고 해당 클래스를 테스트하는 것 말고, 다른 테스트 코드도 이렇게 데이터 베이스를 액세스하고 있다면? 전체적인 테스트 속도는 느려질 것이다. 이러한 문제점을 작은 설계 변경으로 해결할 수 있다!

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class StatCompiler {
   private QuestionController controller = new QuestionController();
   
   public Map<Integer,String> questionText(List<BooleanAnswer> answers) {
      Map<Integer,String> questions = new HashMap<>();
      answers.stream().forEach(answer -> {
         if (!questions.containsKey(answer.getQuestionId()))
            questions.put(answer.getQuestionId(), 
               controller.find(answer.getQuestionId()).getText()); });
      return questions;
   }
 
   public Map<String, Map<Boolean, AtomicInteger>> responsesByQuestion(
         List<BooleanAnswer> answers, Map<Integer,String> questions) {
      Map<Integer, Map<Boolean, AtomicInteger>> responses = new HashMap<>();
      answers.stream().forEach(answer -> incrementHistogram(responses, answer));
      return convertHistogramIdsToText(responses, questions);
   }
 
   private Map<String, Map<Boolean, AtomicInteger>> convertHistogramIdsToText(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, 
         Map<Integer,String> questions) {
      Map<String, Map<Boolean, AtomicInteger>> textResponses = new HashMap<>();
      responses.keySet().stream().forEach(id -> 
         textResponses.put(questions.get(id), responses.get(id)));
      return textResponses;
   }
 
   private void incrementHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, BooleanAnswer answer) {
      Map<Boolean, AtomicInteger> histogram = 
            getHistogram(responses, answer.getQuestionId());
      histogram.get(Boolean.valueOf(answer.getValue())).getAndIncrement();
   }
 
   private Map<Boolean, AtomicInteger> getHistogram(
         Map<Integer, Map<Boolean, AtomicInteger>> responses, int id) {
      Map<Boolean, AtomicInteger> histogram = null;
      if (responses.containsKey(id)) 
         histogram = responses.get(id);
      else {
         histogram = createNewHistogram();
         responses.put(id, histogram);
      }
      return histogram;
   }
 
   private Map<Boolean, AtomicInteger> createNewHistogram() {
      Map<Boolean, AtomicInteger> histogram;
      histogram = new HashMap<>();
      histogram.put(Boolean.FALSE, new AtomicInteger(0));
      histogram.put(Boolean.TRUE, new AtomicInteger(0));
      return histogram;
   }
}
cs

 

questionText() 메서드가 추가 된 것을 볼 수 있다. BooleanAnswer리스트를 매개변수로 받아서 아이디 값에 대응되는 question text를 가져오는 메서드이다. 그리고 나머지 메서드를 보면 이미 데이터베이스에서 액세스해서 가져온 id대question text 해시맵을 매개변수로 받고 있다. 만약 이렇게 코드 설계를 살짝 변경하였다면 테스트 코드는 어떻게 달라질까?!

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class StatCompilerTest {
   @Test
   public void responsesByQuestionAnswersCountsByQuestionText() {
      StatCompiler stats = new StatCompiler();
      List<BooleanAnswer> answers = new ArrayList<>();
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1true));
      answers.add(new BooleanAnswer(1false));
      answers.add(new BooleanAnswer(2true));
      answers.add(new BooleanAnswer(2true));
      Map<Integer,String> questions = new HashMap<>();
      questions.put(1"Tuition reimbursement?");
      questions.put(2"Relocation package?");
      
      Map<String, Map<Boolean,AtomicInteger>> responses = 
            stats.responsesByQuestion(answers, questions);
      
      assertThat(responses.get("Tuition reimbursement?").
            get(Boolean.TRUE).get(), equalTo(3));
      assertThat(responses.get("Tuition reimbursement?").
            get(Boolean.FALSE).get(), equalTo(1));
      assertThat(responses.get("Relocation package?").
            get(Boolean.TRUE).get(), equalTo(2));
      assertThat(responses.get("Relocation package?").
            get(Boolean.FALSE).get(), equalTo(0));
   }
}
cs

 

위와 같이 테스트 코드를 작성할 수 있게 된다. 데이터베이스 액세스하는 로직을 별도의 메서드로 분리하였고, 해당 메서드의 결과를 매개변수로 넘기는 구조로 변경하였기에 테스트 코드를 작성할때는 굳이 데이터베이스를 액세스하지 않고 로컬 메모리 영역 내에서 해결이 가능하게 되었다. 결론적으로 데이터베이스 액세스 시간을 줄이므로써 훨씬 빠른 테스트 코드를 작성할 수 있게 되었다.

 

2)Isolated : 고립된

좋은 단위 테스트는 검증하려는 작은 양의 코드에 집중한다. 이것이 우리가 단위라고 말하는 정의와 부합한다. 직접적 혹은 간접적으로 테스트 코드와 상호 작용하는 코드가 많아질수록 문제가 발생할 소지가 늘어난다.

 

테스트 대상 코드는 데이터베이스를 읽는 다른 코드와 상호 작용할 수도 있다. 데이터 의존성은 많은 문제를 만들기도 한다. 궁극적으로 데이터베이스에 의존해야 하는 테스트는 데이터베이스가 올바른 데이터를 가지고 있는지 확인해야 한다. 그리고 데이터 소스를 공유한다면 테스트를 깨드릴 수 있는 외부 변화도 고민해보아야 한다. 외부 저장소와 상호작용하게 된다면, 꼭 테스트가 가용성 혹은 접근성 이슈로 실패할 가능성이 있다는 것을 염두하자.

 

또 좋은 단위 테스트는 다른 단위 테스트에 의존하지 않는다. 흔히 여러 테스트가 값비싼 초기화 데이터를 재사용하는 방식으로 테스트 순서를 조작하여 전체 테스트의 실행 속도를 높이려 할 수도 있는데, 이러한 의존성은 악순환을 유발할 수 있다. 따라서 테스트 코드는 어떤 순서나 시간에 관계없이 실행할 수 있어야한다.

 

각 테스트가 작은 양의 동작에만 집중하면 테스트 코드를 집중적이고 독립적으로 유지하기 쉬워진다. 하나의 테스트 메서드에 두 번째 assert 구문을 추가할 때, "이 assert 구문이 단일 동작을 검증하도록 돕는가, 아니면 내가 새로운 테스트 이름으로 기술할 수 있는 어떤 동작을 대표하는가"를 스스로 질문해보고 살펴보자.

 

객체지향설계에 대한 이야기이지만 테스트 코드에도 동일하게 적용될 수 있는 단일 책임 원칙(SRP)을 따르자 ! 즉, 테스트 코드도 하나의 단일 행동을 테스트하는 코드여야 한다. 혹여나 테스트 메서드가 하나 이상의 이유로 깨진다면 테스트를 분할하는 것도 고려하자.

 

3)Repeatable : 반복가능한

반복 가능한 테스트는 실행할 때마다 결과가 같아야 한다. 즉, 어떤 상황에서는 성공, 어떤 상황에서는 실패하는 테스트코드를 만들지 말아야한다. 반복가능한 테스트를 만들기 위해서는 직접 통제할 수 없는 외부 환경에 있는 항목들과 격리시켜야 한다. 하지만 시스템은 불가피하게 통제할 수 없는 요소와 상호 작용해야 할 것이다. 예를 들면 현재 시간을 다루어야 하는 테스트 코드는 반복 가능한 테스트를 힘들게 하는 요인이 될 수 있다. 이때는 테스트 대상 코드의 나머지를 격리하고 시간 변화에 독립성을 유지하는 방법으로 목 객체를 사용할 수도 있다.

 

4)Self-validating : 스스로 검증가능한

테스트는 기대하는 결과가 무엇인지 단언(assert)하지 않으면 올바른 테스트라고 볼 수 없다. 단위 테스트는 오히려 개발 시간을 단축시켜준다. main 메서드 안에 표준출력 등을 이용하여 수동으로 검증하는 것은 시간 소모적인 절차고 테스트 위험의 리스크가 증가할 수 있다. JUnit 같은 스스로 검증 가능한 테스트 코드를 작성하거나 더 큰 규모에서는 젠킨스 등 지속적 통합(CI) 도구를 활용할 수 있다. 

 

5)Timely : 적시의

단위테스트를 뒤로 미루지 말고 적시(즉시)에 작성하자. 단위 테스트를 점점 뒤로 미룰 수록 더욱더 테스트를 작성하기 어려워진다.

posted by 여성게
:
Web/TDD 2019. 9. 4. 22:20

 

이번 포스팅에서 다루어볼 내용은 테스트 코드를 잘 조직하고 구조화 할 수 있는 JUnit 기능을 살펴볼 것이다.

포스팅에서 다룰 내용은 아래와 같다.

 

  • 준비-실행-단언을 사용하여 테스트를 가시적이고 일관성 있게 만드는 방법
  • 메서드를 테스트하는 것이 아니라 동작을 테스트하여 테스트 코드의 유지 보수성을 높이는 방법
  • 테스트 이름의 중요성
  • @Before와 @After 애너테이션을 활용하여 공통 초기화 및 정리 코드를 설정하는 방법
  • 거슬리는 테스트를 안전하게 무시하는 방법

 

AAA로 테스트 일관성 유지

 

  1. 준비(Arrange) : 테스트 코드를 실행하기 전에 시스템이 적절한 상태에 있는지 확인한다. 객체들을 생성하거나 이것과 의사소통하거나 다른 API를 호출하는 것 등이다. 드물지만 시스템이 우리가 필요한 상태로 있다면 준비 상태를 생략하기도 한다.
  2. 실행(Act) : 테스트 코드를 실행한다. 보통은 단일 메서드를 호출한다.
  3. 단언(Assert) : 실행한 코드가 기대한 대로 동작하는지 확인한다. 실행한 코드의 반환값 혹은 그 외 필요한 객체들의 새로운 상태를 검사한다. 또 테스트한 코드와 다른 객체들 사이의 의사소통을 검사하기도 한다.
  4. 사후(After) : 때에 따라 테스트를 실행할 때 어떤 자원을 할당했다면 잘 정리되었는지 확인해야한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@FunctionalInterface
public interface Scoreable {
   int getScore();
}
 
public class ScoreCollection {
   private List<Scoreable> scores = new ArrayList<>();
   
   public void add(Scoreable scoreable) {
      scores.add(scoreable);
   }
   
   public int arithmeticMean() {
      int total = scores.stream().mapToInt(Scoreable::getScore).sum();
      return total / scores.size();
   }
}
 
public class ScoreCollectionTest {
   @Test
   public void answersArithmeticMeanOfTwoNumbers() {
      //Arrange
      ScoreCollection collection = new ScoreCollection();
      collection.add(() -> 5);
      collection.add(() -> 7);
      
      //Act
      int actualResult = collection.arithmeticMean();
 
      //Assert
      assertThat(actualResult, equalTo(6));
   }
}
 
cs

 

동작 테스트 vs 메서드 테스트

테스트를 작성할 때는 클래스 동작에 집중해야 하며 개별 메서드를 테스트한다고 생각하면 안된다. 은행의 ATM을 예로 들면, 그 클래스의 메서드에는 deposit(),withdraw(),getBalance() 메서드가 있다.(예금,출금,잔액조회) 

 

만약 위와 같은 메서드를 가진 클래스가 존재하고, 출금에 관한 테스트 코드를 작성한다고 생각해보자. 출금은 하나의 메서드로 구현이 되어 있다. 그러면 출금을 테스트하기 위해서는 딱 withdraw()라는 단일 메서드만 신경쓰면 될까? 아니다. 출금을 위해서는 계좌개설 이후에 먼저 입금이 되어있어야 가능하다. 여기서 말하고자 하는 바는 테스트 코드를 작성한다는 것은 하나의 단일 메서드를 테스트하기 위한 테스트 코드가 아닌 하나의 동작, 혹은 일련의 동작의 모음을 테스트 하기 위한 코드 작성으로 생각하여 전체적인 시각에서 테스트코드를 시작, 작성해야 한다는 것이다.

 

테스트와 프로덕션 코드의 관계

JUnit 테스트는 검증 대상인 프로덕션 코드와 같은 프로젝트에 위치할 수 있다. 하지만 테스트는 주어진 프로젝트 안에서 프로덕션 코드와 분리해야 한다. 

 

단위 테스트는 일방향성이다. 테스트 코드는 프로덕션 코드에 의존하지만, 그 반대는 해당하지 않는다. 프로덕션 코드는 테스트 코드의 존재를 모른다. 하지만 테스트를 작성하는 행위가 프로덕션 시스템의 설계에 영향을 주지 않는다는 것은 아니다. 더 많은 단위 테스트를 작성할수록 설계를 변경했을 때 테스트 작성이 훨씬 용이해지는 경우가 늘어날 수 있다.

 

테스트와 프로덕션 코드 분리

프로덕션 소프트웨어를 배포할 때 테스트를 함께 포함할 수 있지만, 대부분 그렇게 하지 않는다. 그렇게 하면 로딩하는 JAR 파일이 커지고 코드 베이스의 공격 표면(Attack surface)도 늘어난다.

 

  • 테스트를 프로덕션 코드와 같은 디렉터리 및 패키지에 넣기 : 구현하기 쉽지만 어느 누구도 실제 시스템에 이렇게 하지 않는다. 이 정책을 쓰면 실제 배포할 때 테스트 코드를 걷어 내는 스크립트가 필요하다. 클래스 이름으로 구별하거나 테스트 클래스 여부를 식별할 수 있는 리플렉션 코드를 작성해야 한다. 테스트를 같은 디렉터리에 유지하면 디렉터리 목록에서 뒤져야 하는 파일 개수도 늘어난다.
  • 테스트를 별도 디렉터리로 분리하지만 프로덕션 코드와 같은 패키지에 넣기 : 대부분은 이것은 선택한다. 이클립스와 메이븐 같은 도구는 이 모델을 권장한다. src 디렉토리와 별개로 클래스 패스에 test 디렉토리를 두고, 실제 테스트할 클래스의 패키지명을 동일하게 만들어 테스트 클래스를 작성한다. 이렇게 디렉토리와 패키지를 구성하면 각 테스트는 검증하고자 하는 대상 클래스와 동일한 패키지를 갖는다. 즉, 테스트 클래스는 패키지 수준의 접근 권한을 가진다.
  • 테스트를 별도의 디렉터리와 유사한 패키지에 유지하기 : test 디렉터리에 검증 클래스와는 조금 다른 패키지를 생성해 테스트 코드를 유지한다. 테스트 코드를 프로덕션 코드의 패키지와 다르게 하면 public 인터페이스만 활용하여 테스트 코드를 작성한다.

 

단일 목적의 테스트 작성

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1"Got bonuses?");
        criteria = new Criteria();
    }
 
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
   }
   
   @Test
   public void matchAnswersTrueForAnyDonCareCriteria() {
       profile.add(new Answer(question, Bool.FALSE));
       criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
       boolean matches = profile.matches(criteria);
       
       assertTrue(matches);
   }
   
}
 
===============================================================================================
 
public class ProfileTest {
    
    private Profile profile;
    private Question question;
    private Criteria criteria;
    
    @Before
    public void init() {
        profile = new Profile("Kakao");
        question = new BooleanQuestion(1"Got bonuses?");
        criteria = new Criteria();
    }
   @Test
   public void matchAnswerFalseWhenMustMatchCriteriaNotMet() {
      
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.MustMatch));
     
      boolean matches = profile.matches(criteria);
  
      assertFalse(matches);
 
      profile.add(new Answer(question, Bool.FALSE));
      criteria.add(new Criterion(new Answer(question, Bool.TRUE), Weight.DontCare));
       
      boolean matches = profile.matches(criteria);
       
      assertTrue(matches);
   }
   
}
cs

 

만약 위에 같은 테스트 코드가 있는데, 비슷한 테스트 케이스이기에 아래와 같이 테스트 코드를 하나의 메소드로 합쳤다. 이렇게 코드를 리팩토링하면 @Test 애너테이션이 하나이기에 ProfileTest 인스턴스는 하나만 생성되고 @Before 애너테이션이 붙은 초기화코드도 한번만 초기화되면서 두개를 테스트 해볼 수 있다. 하지만 이러한 리팩토링은 아래와 같은 이점을 잃게 된다.

 

테스트를 분리하는 이점

  • Assert가 실패했을 때 실패한 테스트 이름이 표시되기 때문에 어느 동작에서 문제가 있는지 빠르게 파악할 수 있다.
  • 실패한 테스트를 해독하는 데 필요한 시간을 줄일 수 있다. JUnit은 각 테스트를 별도의 인스턴스로 실행하기 때문이다. 따라서 현재 실패한 테스트에 대해 다른 테스트의 영향을 제거할 수 있다.
  • 모든 케이스가 실행되었음을 보장할 수 있다. Assert가 실패하면 현재 테스트 메서드는 중단된다. Assert 실패는 java.lang.AssertionError를 던지기 때문이다.(JUnit은 이것을 잡아 테스트를 실패로 표시한다.) Assert 실패 이후의 테스트 케이스는 실행되지 않는다.

즉, 단일 목적을 가진 테스트로 나누는 것이 좋다.

 

일관성 있는 이름으로 테스트 문서화

테스트 케이스를 단일 메서드로 결합할수록 테스트 이름 또한 일반적이고 의미를 잃어 간다. 좀 더 작은 테스트로 이동할수록 각각은 분명한 행동에 집중한다. 또 각 테스트 이름에 더 많은 의미를 부여할 수 있다. 테스트하려는 맥락을 제안하기 보다는 어떤 맥락에서 일련의 행동을 호출했을 때 어떤 결과가 나오는지를 명시하는 것이 좋다.

 

예제로 행위 주도 개발(BDD, Behavior-Driven Development)에서 말하는 given-when-then 같은 양식을 사용할 수도 있다.

 

ex)givenSomeContextWhenDoingSomeBehaviorThenSomeResultOccurs(주어진 조건에서 어떤 일을 하면 어떤 결과가 나온다.)

 

위와 같은 메서드 이름은 너무 길수도 있다. 그러면 givenSomeContext부분은 제거하여 

 

ex)whenDoingSomeBehaviorThenSomeResultOccurs(어떤 일을 하면 어떤 결과가 나온다.)

 

어느 형식이든지 일관성을 유지하는 것이 중요하다. 주요 목표는 테스트 코드를 다른 사람에게 의미 있게 만드는 것이다. 그리고 주석이 없어도 개발자가 봤을 때, 흐름을 이해할 수 있는 혹은 맥락을 이해할 수 있는 테스크 코드를 작성하는 것이 좋다.

 

@Before와 @After 더 알아보기

연관된 행동 집합에 대해 더 많은 테스트를 추가하면 상당한 테스트 코드가 같은 초기화 부분을 가진다. @Before 메서드를 활용하면 공통적인 초기화 코드를 하나의 메서드로 추출할 수 있어 중복된 코드들을 막을 수 있다.

 

JUnit이 @Before와 @Test 메서드를 어떤 순서로 실행하는지 이해하는 것은 중요하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AssertMoreTest {
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

만약 위와 같은 테스트 클래스가 존재한다고 생각해보자. 실행 순서는 어떻게 될까?

 

  1. @Before
  2. @Test(depositIncreasesBalance)
  3. @After
  4. @Before
  5. @Test(hasPositiveBalance)
  6. @After

 

  1. @Before
  2. @Test(hasPositiveBalance)
  3. @After
  4. @Before
  5. @Test(depositIncreasesBalance)
  6. @After

테스트 코드는 위와 같은 순서로 실행된다. 사실 @Test는 어떠한 순서로 실행될지 알 수 없다. 물론 @FixMethodOrder(MethodSorters.NAME_ASCENDING)와 같은 애너테이션을 테스트 클래스에 붙여 메서드 이름의 내림차순,오름차순으로 순서를 지정할 수는 있다. 하지만 조금 특이한 것이 보인다. 초기화와 후처리 메서드가 매 테스트 메서드마다 실행된다는 점이다. 이것은 즉, @Test 메서드를 수행할 때마다, 테스트 클래스는 새로운 인스턴스를 생성하여 @Before,@After를 다시 실행한다는 것이다.

 

이것은 JUnit의 특징이며 중요한 성질이다. JUnit은 우리에게 독립된 테스트 실행을 제공해주는 것이다.

 

@BeforeClass와 @AfterClass 애너테이션

해당 애너테이션이 붙은 메서드는 어떤 테스트를 처음 실행하기 전에 한 번만 실행된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class AssertMoreTest {
   @BeforeClass
   public static void initializeSomethingReallyExpensive() {
      // ...
   }
   
   @AfterClass
   public static void cleanUpSomethingReallyExpensive() {
      // ...
   }
   
   @Before
   public void createAccount() {
      // ...
   }
   
   @After
   public void closeConnections() {
      // ...
   }
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

실행 순서는 아래와 같다.

 

  1. @BeforeClass
  2. @Before
  3. @Test
  4. @After
  5. @Before
  6. @Test
  7. @After
  8. @AfterClass

테스트제외

만약 테스트에서 제외하고 싶은 메서드가 있다면 @Ignore 애너테이션을 붙여준다. 그리고 결과를 보면 스킵된 테스트 개수를 볼 수 있다.(왼쪽 상단의 Runs: 2/2 (1 skipped) )

 

1
2
3
4
5
6
7
8
9
10
11
12
13
public class AssertMoreTest {
   
   @Test
   public void depositIncreasesBalance() {
      // ...
   }
   
   @Ignore
   @Test
   public void hasPositiveBalance() {
      // ...
   }
}
cs

 

 

여기까지 테스트 코드를 어떻게 조직하며 내부적인 JUnit 동작 메커니즘을 다루어보았다. 사실 너무 간단한 내용만 다루어봤지만 이렇게 하나하나 다루다보면 테스트 코드 작성에 익숙해지는 날이 오지 않을까 싶다.

posted by 여성게
:
Web/TDD 2019. 9. 4. 18:30

 

이번 포스팅에서 다루어볼 예제는 JUnit 3단계 준비,실행,단언 단계중 "단언"의 몇 가지 예제를 다루어볼 것이다. JUnit에 내장된 단언 이외에 햄크레스트(Hamcrest) 라이브러리를 활용한 단언 몇가지도 다루어볼 것이다.

 

JUnit Assert

JUnit에서 assert는 테스트에 넣을 수 있는 정적 메서드 호출이다. 각 Assert 구문은 어떤 조건이 참인지 검증하는 방법이다. 단언한 조건이 참이 아니면 테스트는 그 자리에서 멈추고 실패한다.

 

JUnit은 크게 두 가지 Assert 스타일을 제공한다. 전통적인 스타일의 Assert는 JUnit의 원래 버전에 포함되어 있으며, 새롭고 좀 더 표현력이 좋은 햄크레스트라고 알려진 Assert 구문도 있다.

 

두 가지 Assert 스타일은 각자 다른 환경에서 다른 방식으로 제공된다. 두 가지를 섞어서 사용할 수도 있지만 보통 둘 중 한가지를 선택하여 사용하면 좋다.

 

-assertTrue

가장 기본적인 assert 구문이다.

 

1
org.junit.Assert.assertTrue(BooleanExpression);
cs

 

1
2
3
4
5
6
7
8
9
10
11
@Test
public void hasPositiveBalance() {
   account.deposit(50);
   assertTrue(account.hasPositiveBalance());
}
@Test
public void depositIncreasesBalance() {
   int initialBalance = account.getBalance();
   account.deposit(100);
   assertTrue(account.getBalance() > initialBalance);
}
cs

 

-assertThat

명확한 값을 비교하기 위해 사용한다. 대부분 assert 구문은 기대하는 값과 반환된 실제 값을 비교한다.

 

1
2
3
4
5
6
   @Test
   public void depositIncreasesBalance() {
      int initialBalance = account.getBalance();
      account.deposit(100);
      assertThat(account.getBalance(), equalTo(99));
   }
cs

 

assertThas() 정적 메소드는 햄크레스트 assert의 예이다. 햄크레스트 단언의 첫 번째 인자는 실제 표현식, 즉 우리가 검증하고자 하는 값이다. 두 번째 인자는 매처이다. 매처는 실제 값과 표현식의 결과를 비교한다.

equalTo 매처에는 어떤 자바 인스턴스나 기본형 값이라도 넣을 수 있다. 내부적으로는 equals() 메서드를 사용한다. 자바 기본형은 객체형으로 오토박싱되기 때문에 어떤 타입도 비교할 수 있다.

 

일반적인 assert구문보다 햄크레스트 assert구문이 실패할 경우에 오류 메시지에서 더 많은 정보를 알 수 있다.

 

assertTrue와 동일한 햄크레스트 구문은 아래와 같다.

 

1
2
3
4
5
   @Test
   public void depositIncreasesBalance_hamcrestAssertTrue() {
      account.deposit(50);
      assertThat(account.getBalance() > 0, is(true));
   }
cs

 

이제 기타 햄크레스트 assert 구문을 살펴보자.

 

1
2
3
4
   @Test
   public void matchesFailure() {
      assertThat(account.getName(), startsWith("xyz"));
   }
cs

 

위의 코드는 account.getName()의 문자열이 "xyz"로 시작하는지 테스트하는 코드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
   @Test
   public void comparesArraysFailing() {
      assertThat(new String[] {"a""b""c"}, equalTo(new String[] {"a""b"}));
   }
 
   @Test
   public void comparesArraysPassing() {
      assertThat(new String[] {"a""b"}, equalTo(new String[] {"a""b"}));
   }
 
   @Test
   public void comparesCollectionsFailing() {
      assertThat(Arrays.asList(new String[] {"a"}), 
            equalTo(Arrays.asList(new String[] {"a""ab"})));
   }
 
   @Test
   public void comparesCollectionsPassing() {
      assertThat(Arrays.asList(new String[] {"a"}), 
            equalTo(Arrays.asList(new String[] {"a"})));
   }
cs

 

위 코드는 배열 혹은 리스트 인자들이 동일하게 들어가있는 지 비교하는 assert 구문이다.

 

경우에 따라 is 데코레이터를 추가하여 매처 표현의 가독성을 더 높일 수 있다. is는 디자인패턴의 데코레이터 패턴을 따른다. is는 단지 넘겨받은 매처를 반환할 뿐(즉, 아무것도 하지 않음)이다. 비록 아무일도 하지 않지만 코드의 가독성을 높여줄 수 있다.

 

1
2
3
4
5
6
7
8
@Test
public void variousMatcherTests() {
   Account account = new Account("my big fat acct");
 
   assertThat(account.getName(), is(equalTo("my big fat acct")));
   assertThat(account.getName(), equalTo("my big fat acct"));
 
}
cs

 

위 코드에서 두 줄의 assertThat의 구문이 하는 역할은 동일하다. 하지만 is 표현식이 붙으므로써 가독성을 높혀주는 효과를 줄 수 있다.

 

1
2
3
4
5
6
7
8
9
   @Test
   public void variousMatcherTests() {
      Account account = new Account("my big fat acct");
      
      assertThat(account.getName(), not(equalTo("plunderings")));
      assertThat(account.getName(), is(not(nullValue())));
      assertThat(account.getName(), is(notNullValue()));
 
   }
cs

 

어떠한 결과의 부정이 테스트 성공 결과로 만들고 싶다면 not구문을 넣어주면 된다. 하지만 위에서 보면 null이 아닌 값을 자주 검사하는 테스트 코드가 나온다면 애플리케이션 설계 문제이거나 지나치게 걱정하는 테스트 코드이다. 많은 경우 널 체크는 불필요하고 가치가 없는 테스트일 수 있다.

 

여기서 작은 차이점을 이해해야한다. 만약 테스트 도중에 NPE이 발생한다면, 테스트는 예외를 발생시키고 비교값이 null인 경우에는 테스트가 테스트 실패를 던진다.

 

기타 다른 햄크레스트 매처를 이용하면 아래와 같은 테스트를 진행할 수 있다.

  • 객체 타입을 검사
  • 두 객체의 참조가 같은 인스턴스인지 검사
  • 다수의 매처를 결합하여 둘 다 혹은 둘 중에 어떤 것이든 성공하는지 검사
  • 어떤 컬렉션이 요소를 포함하거나 조건에 부합하는지 검사
  • 어떤 컬렉션이 아이템 몇 개를 모두 포함하는지 검사
  • 어떤 컬렉션에 있는 모든 요소가 매처를 준수하는지 검사

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
   @Test
   public void variousMatcherTests() {
      Account account = new Account("my big fat acct");
      
      assertThat(account.getName(), is(equalTo("my big fat acct")));
 
      assertThat(account.getName(), allOf(startsWith("my"), endsWith("acct")));
 
      assertThat(account.getName(), anyOf(startsWith("my"), endsWith("loot")));
 
      assertThat(account.getName(), not(equalTo("plunderings")));
 
      assertThat(account.getName(), is(not(nullValue())));
      assertThat(account.getName(), is(notNullValue()));
 
      assertThat(account.getName(), isA(String.class));
 
      assertThat(account.getName(), is(notNullValue())); // not helpful
      assertThat(account.getName(), equalTo("my big fat acct"));
   }
 
   @Test
   public void sameInstance() {
      Account a = new Account("a");
      Account aPrime = new Account("a");
      // TODO why needs to be fully qualified??
      assertThat(a, not(org.hamcrest.CoreMatchers.sameInstance(aPrime)));
   }
 
   @Test
   public void moreMatcherTests() {
      Account account = new Account(null);
      assertThat(account.getName(), is(nullValue()));
   }
 
   @Test
   @SuppressWarnings("unchecked")
   public void items() {
      List<String> names = new ArrayList<>();
      names.add("Moe");
      names.add("Larry");
      names.add("Curly");
 
      assertThat(names, hasItem("Curly"));
 
      assertThat(names, hasItems("Curly""Moe"));
 
      assertThat(names, hasItem(endsWith("y")));
 
      assertThat(names, hasItems(endsWith("y"), startsWith("C"))); //warning!
 
      assertThat(names, not(everyItem(endsWith("y"))));
   }
cs

 

부동소수점 수를 두 개 비교

컴퓨터는 모든 부동소수점 수를 표현할 수 없다. 자바에서 부동소수점 타입(float & double)의 어떤 수들은 근사치로 구해야 할 수도 있다. 

 

1
2
3
4
   @Test
   public void doubles() {
       assertThat(2.32*3, equalTo(6.96));
   }
cs

 

위의 테스트는 무사히 통과할 수 있을 까?

 

 

위의 결과처럼 테스트는 실패로 끝나버린다. 그렇다면 부동소수점 비교 테스트는 어떻게 진행할까? 

 

1
2
3
4
   @Test
   public void doubles() {
       assertEquals(2.32*36.960.0005);
   }
cs

 

위와 같이 오차값을 넣어서 테스트를 진행한다. 혹은 햄크레스트의 closeTo 단언문을 사용해도 된다.

 

1
2
3
4
5
6
   import static org.hamcrest.number.IsCloseTo.*;
   
   @Test
   public void doubles() {
       assertThat(2.32*3, closeTo(6.960.0005));
   }
cs

 

발생하길 원하는 예외를 기대하는 세 가지 방법

코드가 항상 기대하는 값을 나오길 기대하는 테스트는 완벽하지 않은 테스트일 수 있다. 때에 따라서 특정 상황에서 어떠한 예외가 발생하길 원하는 테스트를 해봐야할 때도 있다. 어떤 클래스가 예외를 던지는 조건을 이해하면 그 클래스를 사용하는 클라이언트 개발자가 훨씬 사용하기 수월할 것이다.

 

1)단순한 방식: 애너테이션 사용

 

1
2
3
4
   @Test(expected=InsufficientFundsException.class)
   public void throwsWhenWithdrawingTooMuch() {
      account.withdraw(100);
   }
cs

 

위의 코드는 잔고가 없는데, 돈을 인출하여 InsufficientFundsException 예외가 발생하는지 테스트 하는 코드이다. 즉, 예외가 발생해야 테스트가 통과하게 되는 것이다.

 

2)옛 방식: try/catch와 fail

발생한 예외를 처리하는 방법으로 try/catch 블록을 활용할 수도 있다. 예외가 발생하지 않으면 org.junit.Assert.fail() 메서드를 호출하여 강제로 실패한다.

 

1
2
3
4
5
6
7
8
9
10
   @Test
   public void throwsWhenWithdrawingTooMuchTry() {
      try {
         account.withdraw(100);
         fail();
      }
      catch (InsufficientFundsException expected) {
         assertThat(expected.getMessage(), equalTo("balance only 0"));
      }
   }
cs

 

3)새로운 방식: ExpectedException 방식

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
   @Rule
   public ExpectedException thrown = ExpectedException.none();  
   
   @Test
   public void exceptionRule() {
      thrown.expect(InsufficientFundsException.class); 
      thrown.expectMessage("balance only 0");  
      
      account.withdraw(100);  
   }
   
   @Test
   public void exceptionRule2() {
       thrown.expect(NullPointerException.class);
       
       throw new NullPointerException();
   }
cs

 

위와 같이 public 으로 ExpectedException 인스턴스를 생성한 후에 @Rule 애너테이션을 붙여준다. 그리고 테스트 코드에 예외 룰을 작성해준다. 단순히 expect()만 호출한다면 해당 테스트 코드에서 해당 예외가 발생하면 테스트를 통과시키고, expectMessage()까지 호출하면 예외클래스+예외메시지까지 동일해야 테스트가 성공한다.

 

4)예외 무시

만약 테스트 코드에서 발생하는 예외를 무시하고 싶다면 그냥 예외를 던진다.

 

1
2
3
4
5
6
   @Test
   public void exceptionRule2() {
       
       throw new NullPointerException();
   }
cs

 

여기까지 JUnit의 assert 구문 몇 가지를 다루어봤다. 사실 이것보다 더 많은 구문이 존재하기 때문에 기타 다른 구문은 공식 레퍼런스를 이용하자!

posted by 여성게
: