본문 바로가기
Programming/Clean Code

[ATDD] ATDD와 함께 클린 API로 가는 길 3기 - 1주차

by peter paak 2021. 3. 7.
728x90

ATDD

TDD는 왜 어려운가

  1. 실무에 적용하기에는 일정 수준의 숙련도가 필요
    • 테스트 툴에 익숙하지 않다 보니 어느 순간부터 테스트를 작성하는 공수가 더 들때가 있다
    • 그러다 보니 TDD 흐름을 관리하기가 힘들다
    • 레거시 테스트를 지속적으로 관리를 해줘야 하는데 어느 순간부터 손댈 수 없게 되어버린다
    • 테스트의 목적에 포커스를 두기보다는 학문적 목표라는 느낌이 강하다
  2. 도메인부터 단위테스트를 통해 기능을 구현하려 한다
    • 도메인 로직을 모르는 상태에서 시작하면 막막해져버린다
    • 도메인 관계 설계가 어려워서 지우고 생성하다보면 어느순간부터는 잊어버리게 된다
    • 막상 기능 통합을 하면 삐그덕 거린다
    • 서비스가 국한된 부분을 강조하다 보니 전체를 놓치게된다

ATDD

  • 인수테스트를 먼저 구현하고 단위테스트를 구현한다!
    • 인수테스트 기반으로 TDD 가능
    • 무엇을 테스트 해야되는가에 대한 고민을 해결해줄 수 있다
    • 전체적인 흐름을 잃지 않을 수 있다
    • 단위 테스트의 목적을 분명히 할 수 있는 것 같다
    • 레거시 코드에서도 TDD 가능

ATDD 사이클

  1. 인수테스트 먼저 작성
  2. 하위 단위 테스트 작성
  3. 인수테스트 종료시, 기능 구현 종료

상세 ATDD 사이클

  1. 요구사항 정리
  2. 문서화
  3. 인수테스트 작성
  4. 하위 단위테스트 작성
  5. API 개발
  6. 테스트 리펙터링

ATDD 코딩 순서

  1. 인수테스트 작성
  2. 인수테스트 성공
    • 컨트롤러 작성
  3. 단위 테스트 작성
    • 컨트롤러 테스트
    • 컨트롤러 테스트 상세 작성
    • 서비스 추가
    • 컨트롤러 구현
    • 서비스 테스트
    • 서비스 테스트 상세 작성
    • 서비스 구현

만들어져야 될 객체들을 Mock으로 대체한 후 테스트

  • 테스트 하는 대상이 관계를 맺고 있다면 mock객체로 대체 후 테스트
  • 관계를 맺는 클래스가 정의 안되있다면 새로운 클래스 생성
  • OutsideIn

OutsideIn

  • 인터페이스를 만들면서 테스트를 작성
  • 만들어져야될 협력 객체들을 예측하여 대상과 협렵객체의 상호작용 고려
  • 테스트 성공 이후 mock객체에 대한 명세를 시작
  • 다음 테스트의 시작점

ATDD 리펙토링

  1. 테스트의 의도를 명확히 드러내기
// given
지하철_노선_등록되어_있음

// when
지하철_노선_조회_요청

// then
지하철_노선_응답됨
  1. 테스트 코드 중복제거
    • 가독성이 좋지 못한 테스트 코드는 관리가 안된다
    • 테스트 코드도 중복이 많이 발생한다
    • 테스트의 가독성을 높힌다
  2. 중복제거 방법
    • 메서드 분리
    • CRUD 추상화
    • Cucumber나 JBehave 같은 BDD 도구 사용

테스트 픽스쳐

  • 테스트 대상에 필요한 모든 것
    • SUT : 테스트할 대상 (System Under Test)
    • 그 중 공유되는 자원의 관리가 중요하다
  • 테스트는 독립되어야 한다
    • 트스트의 순서는 보장되지 못한다
    • 테스트간 공유되는 자원 관리가 중요하다
  • 테스트간 공유되는 자원
    • Spring Context
    • Database
  • 픽스처 관리
    • 매 테스트 수행 전 공유되는 자원 초기화
    • 매 테스트 수행 전 클라이언트의 request 요청으로 설정
    • 이때 발생하는 중복 코드는 @BeforeEach 메소드로 중복제거

테스트 시나리오 관리

  • 위로 갈 수록 추상적
    • Business Rule 단계에서 인수테스트 시나리오를 작성하는 것이 좋을 것 같다
    • Techincal Activity에서 단위테스트의 향수가 느껴진다
    • 매우 구체적으로 작성
  1. Technical Activity
    • 기술과 관련된 내용으로 시나리오 작성
    • 기술이 바뀌면 테스트 시나리오 수정
1. 최단 경로를 조회할 수 있는 페이지로 접속한다.
2. 조회하고자 하는 경로의 출발지와 도착지를 각각 "강남역"과 "잠실역"으로 **입력**한다.
3. **조회하기 버튼**을 누른다.
4. 결과 페이지에서 출발지와 도착지 사이의 지하철역 목록을 확인한다.
  1. Work Flow
    • 작업 순서를 바탕으로 시나리오 작성
    • 작업 순서가 바뀌면 테스트 시나리오 수정
    • 기술이 바껴도 수정할 필요없다
1. 최단 경로를 조회할 수 있는 페이지로 접속한다.
2. 조회하고자 하는 경로의 출발지와 도착지를 입력한다.
3. 조회한다.
4. 출발역과 도착역 사이의 지하철 역 목록을 확인한다.
  1. Business Rule
    • 비즈니스 규칙에 따라 시나리오 작성
    • 작업 순서, 기술이 바껴도 수정하지 않는다
1. Scenario: 출발역과 도착역 사이의 최단 경로를 조회
2. When: 출발역과 도착역을 기준으로 최단 경로를 조회한다.
3. Then: 출발역과 도착역 사이의 지하철 역을 확인할 수 있다.

테스트 비용

  • 모든 것을 인수테스트로 검증하지 않는다
    • 반드시 필요한 인수조건에 대해 인수테스트로 구현
    • 예외 검증, 사이드 케이스의 경우 단위 테스트로 검증
    • 필요한 인수테스트만 하고 구체적 케이스는 단위테스트로

레거시 리펙토링

  1. 단위 테스트
    • 레거시 코드에 단위테스트 작성
    • 테스트가 깨지지 않은 상태로 리펙토링
    • 도메인 지식이 있을 경우 유용
    • 객체 설계의 어려움 → TDD 사이클 진행이 안됨
    • 도메인이해 부족으로 단위테스트 작성이 어려움
  2. 인수 테스트
    • 레거시 코드에 인수테스트 작성
    • 테스트가 깨지지 않은 상태로 리펙토링
    • 제일 밖부터 단위테스트를 추가하면서 리펙터링
    • 사용자 입장에서 검증하는 인수테스트를 보너스로 추가
    • 도메인 부족 시 유용
@DisplayName("지하철 노선에 등록된 마지막 지하철역을 제외한다.")
@Test
void removeLineStation1() {
     // given
    Long lineId = 지하철_노선_등록되어_있음("2호선", "GREEN");
    Long stationId1 = 지하철역_등록되어_있음("강남역");
    Long stationId2 = 지하철역_등록되어_있음("역삼역");
    Long stationId3 = 지하철역_등록되어_있음("선릉역");

    지하철_노선에_지하철역_등록되어_있음(lineId, null, stationId1);
    지하철_노선에_지하철역_등록되어_있음(lineId, stationId1, stationId2);
    지하철_노선에_지하철역_등록되어_있음(lineId, stationId2, stationId3);

    // when
    ExtractableResponse<Response> deleteResponse = 지하철_노선에_지하철역_제외_요청(lineId, stationId3);

    // then
    지하철_노선에_지하철역_제외됨(deleteResponse);

    // when
    ExtractableResponse<Response> response = 지하철_노선_조회_요청(lineId);

    // then
    지하철_노선에_지하철역_제외_확인됨(response, stationId3);
    지하철_노선에_지하철역_순서_정렬됨(response, Arrays.asList(stationId1, stationId2));
}

단위테스트와 통합테스트

단위테스트

  • 단위는 단일 기능
  • 단위는 메소드에서 클래스까지 다양
  • 테스트 단위가 통합인지 고립인지 먼저 고려
    • 통합 : 협력 객체가 있는 경우
    • 고립 : 협력 객체가 없는 경우
  • 자원이 안정적이라면 굳이 mock으로 고립시킬 필요없이 mockBean 등의 대역 사용도 괜찮다

Test Double

  • 실제 객체 대신 사용되는 모든 객체

Stubbing

  • 호출되면 미리 지정된 답변으로 응답

mockito

public class MockitoTest {
    @Test
    void findAllLines() {
                ...
        LineRepository lineRepository = mock(LineRepository.class);
        StationRepository stationRepository = mock(StationRepository.class);

        when(lineRepository.findAll()).thenReturn(Lists.newArrayList(new Line()));
                ...
    }
}

mockitoExtension

@ExtendWith(MockitoExtension.class)
public class MockitoExtensionTest {
    @Mock
    private LineRepository lineRepository;
    @Mock
    private StationRepository stationRepository;

    @Test
    void findAllLines() {
                ...
        when(lineRepository.findAll()).thenReturn(Lists.newArrayList(new Line()));
                ...
    }
}

springExtension

@ExtendWith(SpringExtension.class)
public class SpringExtensionTest {
    @MockBean
    private LineRepository lineRepository;
    @MockBean
    private StationRepository stationRepository;

    @Test
    void findAllLines() {
                ...
        when(lineRepository.findAll()).thenReturn(Lists.newArrayList(new Line()));
                ...
    }
}

통합 테스트

  • 데이터베이스, 파일 시스템, 외부 라이브러리 등 다른 어플리케이션과 통합되어 개발하는 테스트
  • 외부 라이브러리
    • 외부 라이브러리 기능에 대해 검증할 필요는 없다
    • 단, 사용하는 부분에 대한 검증은 필요
    • 실제 객체 사용한다
    • 외부 라이브러리를 심도있게 이해하기 어려움
    • 목 객체와 실제 객체의 행동을 일치하기 어려움
  • 데이터 베이스
    • 테스트용 메모리 DB에 연결 사용

통합 테스트 vs 단위 테스트

  • 통합 테스트
    • 실제와 가까운 환경에서 검증
    • 정상적 기능 작동 여부
  • 단위 테스트
    • 통합 부분이 정상적으로 동작한다고 가정
    • 단일 기능만 검증
  • 결국, 목적과 상황에 맞게 테스트해라

API 설계

  • 요청 시, url리소스 지정 (ex. www.google.com)
  • 요청 시, method행위 결정 (ex. GET)
  • 요청 시, accept header응답 리소스 표현 결정 (ex. application/json)
  • 요청 시, content-type header보낼 데이터 형식 알림 (ex. application/json)
  • 응답 시, status code응답 결과 요약

Resource

  • 네트워크에서 관리하는 모든 정보
  • 문서, 이미지 등
  • 정확히는 Representation
  • 네트워크를 대표하는 정보라는 뜻인듯

request

// start-line
GET /stations/1 HTTP/1.1

// header
accecpt: application/json
host: localhost:57906
connection: Keep-Alive
user-agent: Apache-HttpClient/4.5.12 (Java/1.8.0_252)
accept-encoding: gzip,deflate

response

// start-line
HTTP/1.1 200 

// header
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 10 Jul 2020 09:38:40 GMT
Keep-Alive: timeout=60
Connection: keep-alive

// message body
{
    "createdDate": "2020-07-10T18:38:40.549",
    "modifiedDate": "2020-07-10T18:38:40.549",
    "id": 1,
    "name": "강남역"
}

Resource Naming

Path

  • 계층 구조를 가진다
    • scheme (required)
    • authority
    • path (required)
    • query
    • fragment
  • authority가 주어진다면 path는 공백이거나 /이어야 한다
  • authority가 없다진다면 path는 //가 아니여야 한다
  • https://tools.ietf.org/html/rfc3986#section-3

path 형식

  1. 복수 리소스 (ex. /customers/)
  2. 단수 리소스 (ex. /customers/{id})
  3. 하위 리소스 (ex. /customers/{id}/account)

Resource 종류별 표현

  1. document
    • 단수개의 리소스 표현
    • /customers/{id}
  2. collection
    • 복수개의 리소스 표현
    • /customers
  3. store
    • 클라이언트에서 관리하는 리소스
    • document와 다르게 고유 식별자 없음
    • 복수로 표현
    • /customers/{id}/account
  4. controller
    • 절차라는 리소스
    • 실행가능한 함수와 유사
    • 매개변수와 반환값이 존재
    • 동사를 사용해도 좋다
    • /resources/{id}/checkout

API 설계 문서 참고

728x90