Unit Test

이완희·2023년 5월 1일
0

테스트 코드는 왜 작성해야 할까?

테스트 코드는 왜 작성해야 할까? 얼른 API 만들고 PostMan에서 API호출 해보는게 더 빠르지 않을까? 절반은 맞고 절반은 틀리다고 생각한다. 테스트 코드를 작성함으로써 시간이 더 소요되는건 사실이지만 얻는 이점이 더 크다. 실제 겪은 예시를 통해 설명해 보겠다.

  1. 문서 역할이 가능하다.

    테스트 코드의 DisplayName과 주석을 적절히 달아둔다면 테스트의 목적을 쉽게 파악할 수 있다. 또한 예외경우들을 보면서 어떠한 데이터를 경계해야 하는지도 알 수 있다.

    @Test
    @DisplayName("신규 기타 운임등록 건을 생성한다. 거리가 0 이하여서 실패한다.")
    @Transactional
    void insert_etcRouteCharge_fail_because_distance_is_negative() throws Exception {
        String payload = asJsonString(Payload.insertEtcRouteCharge_fail_with_distance());
    
        mockMvc.perform(post("/etcRouteCharge:save")
                        .header("Authorization", "key=" + accessTokenTM)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success.length()",is(0)))
                .andExpect(jsonPath("$.fail.[0].message",is("거리는 0 이상 이어야 합니다.")));
    }
  2. 클라이언트 호출 하지 않아도 문제를 미리 발견할 수 있다.

    일전에 내 개발 프로세스는 이러했다.

    Repository에서 JPA작성 → 이를 호출하는 비지니스 로직 작성 → Controller에서 API생성 → Postman에서 API 호출하며 테스트 수행

    테스트 코드는 API 레벨에서 수행하는 것만 몇개 작성하였으며 크게 의미가 있진 않았다. 그저 “잘 호출 했으니 잘 나온다” 정도였다. API를 호출하면 단번에 성공하지는 않았다. NPE등이 발생했으며 원하는 값을 얻기위해 Request나 로직등을 수정 했다. Repository나 Serivce레벨에서 무엇이 잘못되었는지 구체적으로 살피지 않았다. 하지만 TDD(Test Driven Domain)을 접하고 나선 방법이 바뀌었다.

    테스트 시나리오 작성 → Controller에서 API생성 → Service작성 → Repository에서 JPA 작성 → JPA가 원하는 값을 가져오고 있나? Repository Test 작성 → Repo Test가 문제없다면 Service 동작을 위해 Service 테스트 코드 작성 → API 테스트 시나리오를 통해 API호출에 문제가 없는지 확인!

    단계가 많이 늘어났지만 내 코드는 보다 안정성이 높아졌으며 제대로 동작함을 바로바로 확인할 수 있게 됐다. 테스트 커버리지가 크게 높아졌던것도 이때쯤이다.

  3. 코드 수정이 필요한 상황에서 유연하고 안정적인 대응이 가능하다.

    메소드의 로직이 바뀌면 메소드의 단위 테스트를 통해 Success를 봐야한다. 하지만 다른 곳에 영향도가 있는지 확인하기 위해서 일일히 Intergration Test를 수행하는 건 큰 낭비다. 만들어둔 단위 테스트를 수행함으로써 영향도가 얼마나 있는지 쉽게 파악할 수 있다.

  4. 리팩토링시 기능 구현이 동일하게 되었다는 지표가 된다.

    리팩토링을 하고나서 response가 변하면 안된다. 테스트 코드를 만들어뒀다면 response의 변화가 있는지 쉽게 확인할 수 있다. 코드의 구조를 바꾸더라도 동일한 결과를 반환한다는 사실을 테스트 코드를 통해 증명하자. 반대로 말하면, 테스트 코드 없이는 리팩토링을 하지 말자!

즉 코드를 작성하는 것만이 개발이 아니다. 단위테스트까지 작성해야 진정한 개발이라 할 수 있다.

좋은 테스트의 조건

  1. [F]IRST: 테스트는 빠르게 동작하며 자주돌릴수 있어야 한다.
  2. F[I]RST: 각각의 테스트는 독립적이며 서로 영향을 주면 안된다.
  3. FI[R]ST: 어느 환경에서든지 반복 가능해야 한다.
  4. FIR[S]T: 테스트는 성공 또는 실패로 boolean값으로 결과를 내어 자체적으로 검증 되어야 한다.
  5. FIRS[T]: 테스트는 실제 코드를 구현하기 직전에 구현해야 한다.

이외에도 테스트 규약 조건등이 있으나 꼭 알아야 하나 싶어서 작성하지는 않았다. 5가지 정도만 염두해두고 테스트를 작성하자.

테스트 코드를 어떻게 작성했는가?

Layer대로 분리해서 작성했다.

  • Repository Test DB와 통신 테스트를 한다기 보단 JPA나 쿼리가 잘 수행되는지 확인해보는 테스트이다. AAA도 다른 Layer Test에 비해 간단합니다.
    @Test
    @DisplayName("차량 번호를 '3가'로 검색하면 '서울3가4435', '경상3가6670'의 레코드가 검색되어야 한다.")
    void search_full_index() {
       List<Lorry> list = repository.findByVehicleNoContains("3가");
       list.sort(Comparator.comparing(Lorry::getVehicleNo));
    
       Assertions.assertEquals(2, list.size());
       Assertions.assertEquals("경상3가6670", list.get(0).getVehicleNo());
       Assertions.assertEquals("서울3가4435", list.get(1).getVehicleNo());
    }
  • Service Test Service Layer에서 작성한 메소드의 테스트이다. API에서 호출 하는 Service는 여러개의 Service를 호출할 확률이 높으므로 이를 하나씩 테스팅한다. 주의할점은 private service method는 작성하지 않는다. 이를 호출하는 public service method를 테스트 해도 충분하다. 테스트를 위해 private를 public으로 바꾼다는건 뭔가 이상하지 않은가
    @Test
    @DisplayName("서비스_차종별_거리별_요율_조회")
    void tariffRateSearch() {
    doReturn(
                Optional.of(Tariff.builder()
                        .vehicleModelCd("5")
                        .distance(173)
                        .applyStartDt(drivingDt)
                        .unitPrice(new BigDecimal(111720))
                        .build())
        ).when(tariffRepository).findFirstByVehicleModelCdAndDistanceAndApplyStartDtLessThanEqualOrderByApplyStartDtDesc(anyString(),anyInt(),any());
    
        Tariff tariffResult = tariffService.getValidTariff("5", 173, drivingDt);
    
    assertThat(tariffResult.getUnitPrice()).isEqualTo(new BigDecimal(111720));
    }
  • API Test 가장 많은 부분을 포함할 수 있는 Layer이다. 권장하지는 않지만 API Test만으로 Line Coverage를 보장할수 있다. 많은 로직을 포함하고 있는만큼 테스트도 검사할 수 있는 결과가 많아야 한다.
    @Test
    @DisplayName("운행일자로만 정규 편명을 조회한다. 편명이 오름차순으로 조회된다.")
    @Transactional
    void search_all_with_drivingDt() throws Exception {
        String payload =asJsonString(Payload.common_search_only_drivingDt());
    
        mockMvc.perform(post("/routeNamingPlan:searchAll")
                        .header("Authorization", "key=" + accessTokenHQ)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.size()",is(617)))
                .andExpect(jsonPath("$.[0].route_naming",is("BJ1002300")))
                .andExpect(jsonPath("$.[1].route_naming",is("BJ1002400")))
                .andReturn();
    
        String truckingNo1 = "AA2034021";
        String truckingNo2 = "AS4500000";
    
        truckingNoPlanRepository.saveAll(Arrays.asList(
                TruckingNoPlan.builder()
                        .truckingNo(truckingNo1)
                        .applyStartDt("20120101")
                        .settlementOrgCd("100")
                        .departureTmlCd("305")
                        .arriveTmlCd("100")
                        .drivingTypCd("0")
                        .build(),
    
                TruckingNoPlan.builder()
                        .truckingNo(truckingNo2)
                        .applyStartDt("20120101")
                        .settlementOrgCd("100")
                        .departureTmlCd("305")
                        .arriveTmlCd("100")
                        .drivingTypCd("0")
                        .build()));
    
        mockMvc.perform(post("/routeNamingPlan:searchAll")
                        .header("Authorization", "key=" + accessTokenHQ)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.size()",is(619)))
                .andExpect(jsonPath("$.[0].route_naming",is(truckingNo1)))
                .andExpect(jsonPath("$.[1].route_naming",is(truckingNo2)))
                .andExpect(jsonPath("$.[2].route_naming",is("BJ1002300")))
                .andExpect(jsonPath("$.[3].route_naming",is("BJ1002400")))
                .andReturn();
    @Test
    @DisplayName("임시간선계획에서 삭제된 내용은 배차정보에서 조회되지 않아야 한다.")
    @Transactional
    void search_withUnDeleted() throws Exception {
        String payload =asJsonString(Payload.common_search());
        mockMvc.perform(post("/nonScheduled/dispatch:search")
                        .header("Authorization", "key=" + accessToken)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andDo(print())
                .andExpect(jsonPath("$.length()",is((4))));
    }
    @Test
    @DisplayName("운영결과 1000건을 승인한다. 30초 내에 수행되어야한다 (서비스 호출)")
    @Timeout(value = 30)
    void approveDrivingResultBulk1000toService_mustCompleteInTime_success() {
        List<DrivingResultApproveRequest> request = Payload.approveBulkService(300, 1300);
        CommonCommandResponseEx res = drivingResultService.approve(request);
    
        Assertions.assertEquals(request.size(), res.getTotal().intValue());
        Assertions.assertEquals(request.size(), res.getSuccess().size());
        Assertions.assertEquals(0, res.getFail().size());
    }
    물론 부하 테스트를 하는 방법으로는 locust등을 이용할수 있다. 그러므로 모든 API에 대해 부하테스트를 할 필욘없다고 생각한다. 전체 테스트 하는 시간이 매우 오래 소요되는것도 개발의 생산성을 저해한다. 필요한 경우에만 작성하자.
    @Test
    @DisplayName("B2B 정규 왕복 운행결과 2건을 일괄 승인한다. 정상 승인되고, 두 운행건의 운임을 5:5 나눠서 저장한다." +
            "이후 편명2만 승인취소 하면 편명1 운임이 10:0으로 저장된다. 다시 편명2를 승인하면 운임을 5:5로 나눠서 저장한다.")
    void approve_b2b_scheduledRoundTrip_calculateChargeSumAndSplit() throws Exception {
        String drivingId1 = Driving.createScheduledRoutePlanIdToDrivingId(10001L);
        String drivingId2 = Driving.createScheduledRoutePlanIdToDrivingId(10002L);
        String payloadToApproveAll =asJsonString(Arrays.asList(
                ImmutableMap.<String, Object>builder()
                        .put("row_id", 0)
                        .put("driving_id", drivingId1)
                        .put("sts", "u")
                        .build(),
                ImmutableMap.<String, Object>builder()
                        .put("row_id", 1)
                        .put("driving_id", drivingId2)
                        .put("sts", "u")
                        .build()
        ));
    
        mockMvc.perform(post("/b2b/drivingResults:approve")
                        .content(payloadToApproveAll)
                        .header("Authorization", "key=" + accessTokenB2B)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.total").value(2))
                .andExpect(jsonPath("$.success.length()",is(2)))
                .andExpect(jsonPath("$.fail.length()",is(0)));
    
        //운행결과 승인됨
        List<DrivingResult> drivingResultList = drivingResultRepository.findByDrivingIdIn(List.of(drivingId1, drivingId2));
        drivingResultList.forEach(drivingResult -> Assertions.assertEquals(drivingResult.getApprovalStatus(), DrivingResultApproveStatusType.approved));
        DrivingResult dr1 = drivingResultRepository.findById(drivingId1).orElse(DrivingResult.builder().build());
        DrivingResult dr2 = drivingResultRepository.findById(drivingId2).orElse(DrivingResult.builder().build());
    
        //RC 테이블에도 잘 저장되어 있음
        List<RouteCharge> routeChargeList = routeChargeRepository.findByDrivingIdIn(List.of(drivingId1, drivingId2));
        Assertions.assertEquals(routeChargeList.size(), 2);
        routeChargeList.forEach(routeCharge -> Assertions.assertEquals(routeCharge.getSettlementStatus(), SettlementStatusType.notready));
        RouteCharge rc1 = routeChargeRepository.findByDrivingId(drivingId1);
        RouteCharge rc2 = routeChargeRepository.findByDrivingId(drivingId2);
    
        //rc1와 rc2에 고정운임의 절반이 저장되어 있음
        B2BChargeForCalculateVO vo = B2BChargeForCalculateVO.builder()
                .departureTmlCd(dr1.getDepartureTmlCd())
                .arrivalTmlCd(dr1.getArrivalTmlCd())
                .drivingDt(dr1.getDrivingDt())
                .vehicleModelCd(dr1.getVehicleModelCd())
                .corporationRegNo(dr1.getCorporationRegNo())
                .drivingTypeCd(FixedTariffDrivingType.B2B왕복.getValue())
                .build();
        BigDecimal fixedTariff = fixedTariffSpec.getFixedTariff(vo).getContractTransFee();
        BigDecimal fuelCost = fixedTariffSpec.getFixedTariff(vo).getFuelCost();
    
        Assertions.assertEquals(rc1.getCharge(), fixedTariff.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc1.getEtcCost(), fuelCost.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc1.getApprovalStatus(), DrivingResultApproveStatusType.approved);
        Assertions.assertEquals(rc2.getCharge(), fixedTariff.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc2.getEtcCost(), fuelCost.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc2.getApprovalStatus(), DrivingResultApproveStatusType.approved);
        Assertions.assertEquals(rc1.getCharge().add(rc1.getEtcCost()), rc1.getTotalCharge());
        Assertions.assertEquals(rc2.getCharge().add(rc2.getEtcCost()), rc2.getTotalCharge());
    
        //rc2를 승인취소 한다.
        String payloadToCancel =asJsonString(Arrays.asList(
                ImmutableMap.<String, Object>builder()
                        .put("row_id", 0)
                        .put("driving_id", drivingId2)
                        .put("sts", "u")
                        .build()
        ));
    
        mockMvc.perform(post("/b2b/drivingResults:cancel")
                        .content(payloadToCancel)
                        .header("Authorization", "key=" + accessTokenB2B)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.total").value(1))
                .andExpect(jsonPath("$.success.length()",is(1)))
                .andExpect(jsonPath("$.fail.length()",is(0)));
    
        Assertions.assertEquals(rc2.getApprovalStatus(), DrivingResultApproveStatusType.notapproved);
        Assertions.assertEquals(dr2.getApprovalStatus(), DrivingResultApproveStatusType.notapproved);
    
        //rc1와 rc2는 운임이 10:0으로 저장되어 있다.
        Assertions.assertEquals(rc1.getCharge(), fixedTariff);
        Assertions.assertEquals(rc1.getEtcCost(), fuelCost);
        Assertions.assertEquals(rc2.getCharge(), BigDecimal.ZERO);
        Assertions.assertEquals(rc2.getEtcCost(), BigDecimal.ZERO);
    
        //rc2를 승인한다.
        String payloadToReApprove =asJsonString(Arrays.asList(
                ImmutableMap.<String, Object>builder()
                        .put("row_id", 0)
                        .put("driving_id", drivingId2)
                        .put("sts", "u")
                        .build()
        ));
    
        mockMvc.perform(post("/b2b/drivingResults:approve")
                        .content(payloadToReApprove)
                        .header("Authorization", "key=" + accessTokenB2B)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.total").value(1))
                .andExpect(jsonPath("$.success.length()",is(1)))
                .andExpect(jsonPath("$.fail.length()",is(0)));
    
        Assertions.assertEquals(rc2.getApprovalStatus(), DrivingResultApproveStatusType.approved);
        Assertions.assertEquals(dr2.getApprovalStatus(), DrivingResultApproveStatusType.approved);
    
        //rc1와 rc2에 운임이 5:5로 저장되어 있다.
        Assertions.assertEquals(rc1.getCharge(), fixedTariff.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc1.getEtcCost(), fuelCost.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc2.getCharge(), fixedTariff.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc2.getEtcCost(), fuelCost.divide(new BigDecimal(2)));
        Assertions.assertEquals(rc1.getCharge().add(rc1.getEtcCost()), rc1.getTotalCharge());
        Assertions.assertEquals(rc2.getCharge().add(rc2.getEtcCost()), rc2.getTotalCharge());
    }

테스트 코드 작성 팁을 공유해보자

  1. 긍정적인 테스트만 작성하지 말자.

    개발자는 당연히 자신이 작성한 코드가 단번에 통과되길 바란다. 그래서 유효한 데이터를 넣고 원하는 결과를 반환하는 테스트를 작성한다. 잘못되었다는건 아니지만 실패할때 어떻게 대처할 것인지에 대한 테스트도 필요하다. 예를들어 편명은 9자리인데 request에서 10자리 편명이 들어온다면 어떻게 할것인가?

    주의해야 할것은 필수값 누락으로 Service Layer에서 NPE가 발생한다고 Negative Test가 수행된 것은 아닙니다. Controller Layer에서 값을 검증하여 사용자에게 필수값이 누락되었다는 것을 알려야 합니다.

    @Test
    @Transactional
    @DisplayName("B2B편명 신규 등록하지만 호차가 199을 넘었으므로 실패한다.")
    void save_b2b_fail_over_199_dispatch_ser() throws Exception {
        String dispatchSer = "200";
        String settlementOrgCd = "801";
        String payload =asJsonString(Payload.save_B2B(settlementOrgCd, dispatchSer, StsType.i.toString()));
    
        mockMvc.perform(post("/v1/B2B/truckingNoManagement:save")
                        .header("Authorization", "key=" + accessTokenB2B)
                        .contentType(MediaType.APPLICATION_JSON)
                        .accept(MediaType.APPLICATION_JSON)
                        .content(payload))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.total",is(1)))
                .andExpect(jsonPath("$.fail[0].message").value("배차번호는 101과 199사이어야 합니다."));
    }
  2. 메소드 명은 요구사항을 나타내자

    메소드 명을 작성하는건 매우 까다로운 작업이다. 하지만 테스트 코드는 문서의 기능을 한다고 했다. 따라서 메소드 명을 통해 행위를 유추할 수 있어야 한다.

    save_isOK → 어떤 테스트를 수행했고, 왜 OK인지 알수 없음

    save_non_satisfied_failed → 어떤 테스트를 수행했고, 왜 fail인지 알수 없음

    • MethodName_StateUnderTest_ExpectedBehavior
      • saveRouteCharge_exceedMaximumCharge_Fail()
      • saveDrivingEvent_statusMustBeStandBy_Success
    • When_StateUnderTest_Expect_ExpectedBehavior
      • when_RouteChargeSearchRequest_isValid_Success()
      • when_seacrhByTmlCdByTmlOwner_successWithAuth()
    • Should_ExpectedBehavior_When_StateUnderTest
      • should_fail_when_invlidRequestTmlCdEnum()

    메소드 명으로 한글을 써도 문제가 안된다면 한글로 작성해도 된다! 나는 체크스타일에서 한글로 작성하면 안되기 때문에 열심히 영어로 적어야 한다.

마치며

SI회사를 다닐때 테스트 코드에 대해 알지도 못하였고 회사에서 어떠한 지침도 없었다. Unit Test보단 Intergration Test와 UI Test만 진행했었다. 어쩔수 없는 SI의 한계라고 생각한다. 촉박한 기간 내에 얼른 개발 완료하고 나가야하니까. 다만 Project(또는 Product)의 Ownership이 본인에게 있다고 생각하면 반드시 테스트 코드를 작성하자. 단기적으론 시간을 더 써야한다고 생각할지 몰라도 장기적으론 시간을 아끼게 해준다. 이후에 난 어떤 Product를 만들더라도 Line Coverage는 최소 70%이상 챙길것이다.

profile
인생을 재밌게, 자유롭게

0개의 댓글