Post

테스트하기 좋은 코드로 리팩토링

🎬 Intro

사이드플젝인 OOTC에서 사용하는 코드를 테스트 하기 좋은 코드로 리팩토링 해보겠습니다.

✅ 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
40
41
42
43
44
45
46
47
@Component
@Slf4j
public class NationalForecastRegionReader {
    

    public List<Region> read() {

        try {
            ClassPathResource resource = new ClassPathResource("national_forecast_regions.csv");
            CSVReader csvReader = new CSVReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8));
            List<Region> regions = new ArrayList<>();
            String[] fields;

            // 첫 번째 줄(헤더)을 읽고 무시
            csvReader.readNext();

            while ((fields = csvReader.readNext()) != null) {
                if (fields.length < 6) {
                    continue; // 잘못된 형식의 라인은 무시합니다.
                }
                Region region = convertToRegion(fields);
                regions.add(region);
            }
            return regions;
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new RuntimeException("national_forecast_regions.csv을 읽어오는데 실패하였습니다.");
        }

    }

    private Region convertToRegion(String[] fields) {
        String code = fields[0];
        String city = fields[1];
        String district = fields[2];
        String neighborhood = fields[3];
        String nx = fields[4];
        String ny = fields[5];

        Address address = new Address(code, city, district, neighborhood);
        return Region.builder()
                .address(address)
                .nx(nx)
                .ny(ny)
                .build();
    }
}
  • 위 코드는 해당 클래스의 메서드에서 ClassPathResource, CSVReader를 정의하고 있어서 강결합 상태입니다.
  • 이 경우에는 테스트 코드 작성 시 Mocking을 하는것이 불가능 하므로 외부에서 주입받는 형태로 리팩토링해야합니다.

✅ After

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class FileReader {

    @Value("${national-forecast-regions}")
    private String fileName;

    @Bean
    @Qualifier("national_forecast_regions")
    public CSVReader csvReader() throws Exception {
        ClassPathResource resource = new ClassPathResource(fileName);
        return new CSVReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8));
    }
}
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
@Component
@Slf4j
public class NationalForecastRegionReader {

    private final CSVReader csvReader;

    public NationalForecastRegionReader(@Qualifier("national_forecast_regions") CSVReader csvReader) {
        this.csvReader = csvReader;
    }

    public List<Region> read() {

        try {
            List<Region> regions = new ArrayList<>();
            String[] fields;

            // 첫 번째 줄(헤더)을 읽고 무시
            csvReader.readNext();

            while ((fields = csvReader.readNext()) != null) {
                if (fields.length < 6) {
                    continue; // 잘못된 형식의 라인은 무시합니다.
                }
                Region region = convertToRegion(fields);
                regions.add(region);
            }
            return regions;
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new RuntimeException("national_forecast_regions.csv을 읽어오는데 실패하였습니다.");
        }

    }

    private Region convertToRegion(String[] fields) {
        String code = fields[0];
        String city = fields[1];
        String district = fields[2];
        String neighborhood = fields[3];
        String nx = fields[4];
        String ny = fields[5];

        Address address = new Address(code, city, district, neighborhood);
        return Region.builder()
                .address(address)
                .nx(nx)
                .ny(ny)
                .build();
    }
}
  • csvReader를 빈으로 등록하고, 이를 생성자 주입을 통해 외부에서 주입받는 형태로 리팩토링하였습니다.
  • 참고로 어떠한 빈에 생성자가 오직 하나만 있고, 생성자의 파라미터 타입이 빈으로 등록가능한 존재라면 이 빈은 @Autowired 애노티에션 없이도 자동으로 의존성 주입이 됩니다.
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
// @SpringBootTest 를 붙여도 되지만 그러면 모든 스프링 컨텍스트가 로드 되기 때문에 느려진다.
@ExtendWith(MockitoExtension.class)
class NationalForecastRegionReaderTest {

    @InjectMocks // @Mock으로 선언된 얘들을 @InjectMocks에 붙은곳에 자동으로 선언해준다.
    private NationalForecastRegionReader nationalForecastRegionReader;

    @Mock
    private CSVReader csvReader;

    @BeforeEach
    public void setUp() throws Exception {
        when(csvReader.readNext()).thenReturn(
                new String[]{"code", "city", "district", "neighborhood", "nx", "ny"},
                new String[]{"001", "서울특별시", "강남구", "삼성동", "60", "127"},
                new String[]{"002", "부산광역시", "해운대구", "정동", "98", "76"},
                null
        );
    }

    @Test
    public void testRead_정상_케이스() {
        List<Region> regions = nationalForecastRegionReader.read();

        assertEquals(2, regions.size());
        assertEquals("001", regions.get(0).getAddress().getCode());
        assertEquals("서울특별시", regions.get(0).getAddress().getCity());
        assertEquals("강남구", regions.get(0).getAddress().getDistrict());
        assertEquals("삼성동", regions.get(0).getAddress().getNeighborhood());
        assertEquals("60", regions.get(0).getNx());
        assertEquals("127", regions.get(0).getNy());
    }

    @Test
    public void testRead_잘못된_형식의_라인_무시() throws Exception {
        when(csvReader.readNext()).thenReturn(
                new String[]{"code", "city", "district", "neighborhood", "nx", "ny"},
                new String[]{"001", "서울특별시", "강남구", "삼성동", "60", "127"},
                new String[]{"002", "부산광역시"},
                null
        );

        List<Region> regions = nationalForecastRegionReader.read();

        assertEquals(1, regions.size());
        assertEquals("001", regions.get(0).getAddress().getCode());
        assertEquals("서울특별시", regions.get(0).getAddress().getCity());
        assertEquals("강남구", regions.get(0).getAddress().getDistrict());
        assertEquals("삼성동", regions.get(0).getAddress().getNeighborhood());
        assertEquals("60", regions.get(0).getNx());
        assertEquals("127", regions.get(0).getNy());
    }

    @Test
    public void testRead_예외_발생() throws Exception {
        when(csvReader.readNext()).thenThrow(new RuntimeException("파일 읽기 실패"));

        RuntimeException exception = assertThrows(RuntimeException.class, () -> {
            nationalForecastRegionReader.read();
        });

        assertThat(exception.getMessage()).isEqualTo("national_forecast_regions.csv을 읽어오는데 실패하였습니다.");
    }
}
  • csvReader가 외부에서 주입받는 형태로 리팩토링 되었기 때문에 Mocking이 가능해졌습니다.

  • @ExtendWith(MockitoExtension.class)
    • 목적: Mockito를 사용한 단위 테스트(Unit Test) 설정.
    • 사용 용도: 주로 단위 테스트에서 Mockito를 활용해 모의(Mock) 객체를 주입하고 테스트할 때 사용됩니다.
    • 범위: 스프링 컨텍스트를 로드하지 않으며, 필요한 객체만 모의하여 테스트하는 데 사용됩니다.
    • 속도: 스프링 컨텍스트를 로드하지 않으므로 테스트가 매우 빠르게 실행됩니다.
  • @SpringBootTest
    • 목적: 스프링 부트 애플리케이션 컨텍스트를 로드하고 통합 테스트(Integration Test)를 수행.
    • 사용 용도: 애플리케이션의 전체 컨텍스트를 로드하여 실제 빈(bean)들을 테스트할 때 사용됩니다.
    • 범위: 스프링 컨텍스트를 포함한 애플리케이션 전체를 로드합니다.
    • 속도: 전체 애플리케이션 컨텍스트를 로드하므로 테스트 실행 속도가 느릴 수 있습니다.
This post is licensed under CC BY 4.0 by the author.