BackEnd/TDD

[TDD] 테스트 코드 작성 팁 (2/2)

샤아이인 2022. 1. 30.

해당 글은 "테스트 주도 개발 시작하기 - 최범균 저" 의 10장 내용을 공부, 기록겸 요약한 글 입니다.

지난 번 글에 이어서, 나머지 5가지 테스트 작성의 팁에 대하여 알아보자.

 

6. 실행 환경이 다르다고 실패하지 않기

같은 테스트 메소드가 실행 환경에 따라 성공하거나 실패하면 안된다.

로컬 개발 환경에서는 성공하는데 빌드 서버에서는 실패한다거나 윈도우에서는 성공하는데 맥 OS에서는 실패하는 식으로 테스트를 실행하는 환경에 따라 테스트를 다르게 동작하면 안된다.

 

이 전형적인 예가 바로 파일경로이다.

public class BulkLoaderTest{
    private String bulkFilePath = "d:\\mywork\\temp\\bulk.txt";

    @Test
    void load(){
        BulkLoader loader = new BulkLoader();
        loader.load(bulkFilePath);

        ...생략
    }
}

이 파일경로는 D드라이브를 포함한다. D드라이브가 없는 맥 OS에서는 이 테스트를 실행하면 항상 실패한다.

이렇기 때문에 테스트에서 사용하는 파일은 프로젝트 폴더를 기준으로 상대 경로를 사용해야 한다.

 

예를 들어 "src/test/resources"와 같은 폴더에 "bulk.txt" 파일을 생성하고 테스트 코드는 상대경로로 사용한다.

public class BulkLoaderTest{
    private String bulkFilePath = "src/test/resources/bulk.txt";

    @Test
    void load(){
        BulkLoader loader = new BulkLoader();
        loader.load(bulkFilePath);

        ...생략
    }
}

 

테스트 코드에서 파일을 생성하는 경우에도 특정 OS나 본인의 개발 환경에서만 올바르게 동작하지 않도록 주의해야 한다.

 

메이븐 프로젝트라면 target폴더에 파일 생성 결과를 저장 하거나, OS가 제공하는 임시 폴더에 파일을 생성하면 실행 환경에 따라 테스트가 다르게 동작하는 것을 방지할 수 있다.

public class ExporterTest{

    @Test
    void export(){
        String folder = System.getProperty("java.io.tmpdir");
        Exporter exporter = new Exporter(folder);
        exporter.export("file.txt");

        Path path=Paths.get(folder,"file.txt");
        assertTrue(Files.exists(path));
    }
}

위 코드는 실행 환경에 알맞은 임시 폴더 경로를 구하여 작동하기 때문에, 환경이 달라 테스트가 실패하는 상황은 생기지 않는다.

 

7. 실행 시점이 다르다고 실패하지 않기

다음 코드는 회원의 만료 여부를 확인하는 기능을 제공한다.

public class Member{
    private LocalDateTime expiryDate;

    public boolean isExpired(){
    	return expiryDate.isBefore(LocalDateTime.now());
    }
}

이 기능의 검증을 위한 테스트 코드는 다음과 같다.

@Test
void notExpired(){
    //테스트 코드를 작성한 시점이 2019년 1월 1일
    LocalDateTime expiry = LocalDateTime.of(2019,12,31,0,0,0);
    Member m = Member.builder().expiryDate(expiry).build();
    assertFalse(m.isExpired());
}

이 코드는 만료일을 2019년 12월 31일 0시 0분 0초로 설정하고 isExpired() 메소드의 결과가 false인지 확인한다.

이 코드를 작성한 시점인 2019년 1월 1일에는 테스트에 문제가 발생하지 않지만 2019년 12월 31일에 테스트를 실행하면 깨진다.

 

따라서 테스트 코드에서 시간을 명시적으로 제어할수 있는 방법을 선택해야 한다.

대표적으로 값을 파라미터로 넘기는 방식이 있다.

 

Member.isExpired() 의 경우 시간을 파라미터로 전달받아 비교하는 방법을 사용한다.

public class Member{
    private LocalDateTime expiryDate;

    public boolean passedExpiryDate(LocalDateTime time){
    	return expiryDate.isBefore(time);
    }
}

따라서 테스트 코드는 다음과 같이 변경된다.

@Test
void notExpired(){
    //테스트 코드를 작성한 시점이 2019년 1월 1일
    LocalDateTime expiry = LocalDateTime.of(2019,12,31,0,0,0);
    Member m = Member.builder().expiryDate(expiry).build();
    assertFalse(m.passedExpiryDate(LocalDateTime.of(2019,12,30,0,0,0)));
}

위 테스트 코드는 실행 시점에 상관없이 항상 통과한다.

또한 시간을 전달하면 경계 조건도 쉽게 테스트 할수가 있다.

 

추가적으로 별도의 시간 클래스를 작성하여 사용할수도 있다.

 

1) 랜덤하게 실패하지 않기

실행시점마다 테스트의 결과가 달라지는 대표적 예로 랜덤값을 사용하는 문제가 있다.

예를 들어 숫자 야구 게임을 위한 Game 클래스의 생성자에서 Random을 이용해 숫자를 생성한다고 해보자.

public class Game{
    private int[] nums;

    public Game(){
        Random random = new Random();
        int firstNo = random.nextInt(10);
        ...
        this.nums = new int[] {firstNo, secondNo, thridNo};
    }
    
    public Score guess(int ... answer){
    	...생략
    }
}

생성자에서 랜덤하게 숫자 3개를 할당하고 있다.

 

Game 클래스를 검증하는 테스트 코드를 작성해보고 싶지만, 정답을 알수가 없기 때문에 테스트를 하고 싶어도 테스트 코드를 작성할수가 없다.

 

랜덤하게 생성한 값이 결과검증에 영향을 준다면 구조를 변경해야만 한다.

Game 같은 경우 직접 생성자에서 랜덤값을 생성하는것이 아니라, 외부에서 생성자의 인자를 통해 값을 넘겨받도록 해야한다.

public class Game{
    private int[] nums;
    public Game(int[] nums){
    	...값 확인 코드
    	this.nums = nums; // 외부에서 넘어온 값, 게임의 정답으로 사용될 값
    }
}

또는 랜덤 값 생성을 다른 객체에게 위임하게 바꿔도 된다. 다음과 같이 별도의 클래스를 만드는 것 이다.

public class GameNumGen{
    public int[] generate(){
    	...랜덤하게 값 생성
    }
}

이제 Game 클래스는 GameNumGen을 이용하여 랜덤값을 넘겨받게 된다.

public class Game{
    private int[] nums;
    
    public Game(gameNumGen gen){
    	nums = gen.generate();
    }
}

테스트 코드는 GameNumGen의 대역을 사용하여 테스트 할수가 있다.

@Test
void noMatch(){
    //랜덤 값 생성을 별도 타입으로 분리하고
    //이를 대역으로 대체해서 대체
    GameNumGen gen = mock(GameNumGen.class); // 대역 생성
    given(gen.generate()).willReturn(new int[]{1, 2, 3}); // 대역 반환값 설정하기

    Game g = new Game(gen);
    Score s = g.guess(4,5,6);
    assertEquals(0, s.strikes());
    assertEquals(0, s.balls());
}

 

8. 필요하지 않은 값은 설정하지 않기

다음은 중복아이디를 가진 회원은 가입할 수 없다는것을 검증하는 테스트 코드이다.

@Test
void dupIdExists_Then_Exception(){
    //동일 ID가 존재하는 상황
    memoryRepository.save(User.builder().id("dupid").name("이름")
                            .email("abc@abc.com")
                            .password("abcd")
                            .regDate(LocalDateTime.now())
                            .build());

    RegisterReq req = RegisterReq.builder()
        .id("dupid").name("다른이름")
        .email("dupid@abc.com")
        .password("abcde")
        .build()

    assertThrows(DupIdException.class, () -> userRegisterSvc.register(req));
}

위 테스트 코드를 잘못 만든 것은 아니지만, 검증할 내용에 비하면 필요하지 않은 값까지 설정하고 있다.

 

테스트는 동일 ID가 존재하는 경우에 가입할 수 없는지를 검증하는 것이 목적이기 때문에 동일 ID가 존재하는 상황을 만들 때 이메일, 이름, 가입일과 같은 값은 필요하지 않다.

RegisterReq객체를 생성할 때에도 검증에 필요한 값만 지정하면 된다.

@Test
void dupIdExists_Then_Exception(){
    //동일 ID가 존재하는 상황
    memoryRepository.save(User.builder().id("dupid").build());

    RegisterReq req = RegisterReq.builder().id("dupid").build();
        
    assertThrows(DupIdException.class, () -> userRegisterSvc.register(req));
}

테스트에 필요한 값만 설정하면 필요하지 않은 값을 설정하느라 고민할 필요가 없다. 또한, 테스트 코드가 짧아져서 한눈에 내용을 파악할 수 있다.

 

9. 조건부로 검증하지 않기

테스트는 성공하거나 실패해야 한다. 테스트가 성공하거나 실패하렴녀 반드시 단언을 실행해야 한다.

만약 조건에 따라서 단언을 하지 않으면 그 테스트는 성공하지도 실패하지도 않은 테스트가 된다.

이런 문제가 발생하는 예를 보자.

@Test
void canTranslateBasicWord(){
    Translator tr = new Translator();
    
    if(tr.contains("cat")){
    	assertEquals("고양이", tr.translate("cat"));
    }
}

위 코드의 tr.contains("cat") 이 true가 아니면 assertEquals를 실행하지 않는다.

만약 이 코드의 목적이 "cat"정도의 기본 단어는 번역을 할 수 있어야 한다는 것을 테스트하는 것이 목적이라면 이는 문제가 된다.

왜냐면 tr.contains("cat") 이 false를 리턴하면 테스트가 실패하지 않기 때문이다.

 

이런 문제가 발생하지 않으려면 조건에 대한 단언도 추가해야한다.

@Test
void canTranslateBasicWorld(){
    Translator tr = new Translator();
    assertTranslationOfBasicWord(tr,"cat");
}

private void assertTranslationOfBasicWord(Translator tr, String word){
    assertTrue(tr.contains("cat"));
    assertEquals("고양이",tr.translate("cat"));
}

tr.translate("cat") 을 단언하기에 앞서 tr.contains("cat")이 true 인지 검사한다. 이렇게 함으로써 실패한 테스트를 놓치는 것을 방지할 수 있다.

 

10. 통합 테스트는 필요하지 않은 범위까지 연동하지 않기

다음 코드를 살펴보자.

@Component
public class MemberDao {
    private JdbcTemplate jdbcTemplate;

    public MemberDao(JdbcTemplate jdbcTemplate){
    	this.jdbcTemplate= jdbcTemplate;
    }

    public List<Member> selectAll(){
    	...생략
    }
}

위 코드는 스프링의 JdbcTemplate을 이용해서 데이터를 연동하고 있다.

스프링 부트프로젝트를 사용한다면 다음과 같은 코드를 이용해서 DB연동 테스트를 진행할 수 있다.

 

@SpringBootTest
public class MemberDaoIntTest{
    @Autowired MemberDao dao;

    @Test
    void findAll(){
        List<Member> members=dao.selectAll();
        assertTrue(members.size() > 0);
    }
}

 

위 테스트코드는 잘못 만들지는 않았지만 한 가지 단점이 있다.

테스트하는 대상은 DB와 연동을 처리하는 MemberDao인데 @SpringBootTest 애노테이션을 사용하면 서비스, 컨트롤러 등 모든 스프링 빈을 초기화한다는 것이다.

 

DB 관련된 설정 외에 나머지 설정도 처리하므로 스프링을 초기화하는 시간이 길어질 수 있다.

스프링 부트가 제공하는 @JdbcTest 애노테이션을 사용하면 DataSoruce, JdbcTemplate등 DB연동과 관련된 설정만 초기화 한다.

다른 빈을 생성하지 않으므로 스프링을 초기화하는 시간이 짧아진다. 다음은 @JdbcTest 애노테이션을 이용예를 보여준다

@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberDaoJdbcTest{

    @Autowired JdbcTemplate jdbcTemplate;

    private MemberDao dao;

    @BeforeEach
    void setUp(){
        dao = new MemberDao(jdbcTemplate);
    }
    
    @Test
        void findAll(){
        ...생략
    }
}

위 코드는 MemberDao객체를 직접 생성하고 있지만 대신 확인에 필요한 스프링 설정만 초기화하고 테스트할 수 있는 장점이 있다.

DataSource와 JdbcTemplate을 테스트 코드에서 직접 생성하면 스프링 초기화 과정이 빠지므로 테스트 시간은 더 짧아질 것이다.

댓글