테스트는 왜 해야 할까 ?

Seunghee Lee·2024년 3월 27일

캡스톤

목록 보기
9/10

테스트 코드란

테스트 코드(Test Code)는 소프트웨어의 기능과 동작을 테스트 하는 코드를 말한다. 테스트 코드에는 단위 테스트(Unit Testing), 통합 테스트(Integration Testing), 시스템 테스트(System Testing), 인수 테스트(Acceptance Testing) 등 다양한 종류가 있으며, 각각의 테스트는 특정한 측면에서 소프트웨어를 평가한다. 특정한 측면이라 함은 테스트 대상의 범위가 다름을 얘기한다.

📍 단위 테스트

단위 테스트(Unit Test)는 하나의 모듈을 기준으로 독립적으로 진행되는 가장 작은 단위의 테스트이다. 여기서 모듈은 하나의 기능 또는 메서드로 이해하면 된다. 예를 들어, 로그인 메서드가 있다면 독립적인 하나의 테스트가 1개의 단위 테스트가 될 수 있다.

즉, 단위 테스트는 애플리케이션을 구성하는 하나의 기능이 올바르게 동작하는지 독립적으로 테스트한다.

📍 통합 테스트

통합 테스트(Integration Test)는 모듈을 통합하는 과정에서 모듈 간의 호환성을 확인하기 위해 수행되는 테스트이다. 애플리케이션은 여러 모듈로 구성되어 있고, 모듈들끼리 함수 호출을 통해 기능을 수행한다. 따라서 통합 모듈들이 올바르게 연계되어 동작하는지 검증이 필요한데, 이러한 목적으로 진행되는 테스트가 통합 테스트이다.

즉, 통합 테스트는 웹 페이지로부터 API 를 호출하여 올바르게 동작하는 지를 확인하는 테스트이다.

📍 시스템 테스트

시스템 테스트(System Test)는 통합된 전체 시스템이나 애플리케이션이 지정된 요구사항을 충족하는지 평가한다. 이는 개별 모듈과 컴포넌트가 통합된 후 시스템의 기능성과 비기능적 요구사항을 평가하는 소프트웨어 테스팅의 한 유형이다.

📍 인수 테스트

인수 테스트(Acceptance Test)는 소프트웨어가 사용자 요구사항과 기대를 만족하는지 검증한다. 이는 요구사항을 명확히 하고, 구현 전에 테스트를 수행함으로써 생산성을 높이는 데 도움을 준다. 인수 테스트는 요구사항을 충족하는지 확인하는 데 중점을 둔다.


테스트를 하는 이유

그렇다면 테스트 코드를 하는 이유는 뭘까 ?

개발하면서 테스트 코드를 구현하는 것은 중요하다는 소리를 종종 듣기는 했지만 그것이 도대체 왜 필요한지 의문이 들었다. 블로그나 주변 연구실 사람들에게 여쭤보면 "정확히 동작하는지 확인해보기 위해서" 라는 답이 대부분이었다. 근데 오히려 나는 여기서 더 의문이 들었다.

아니 굳이 테스트 코드로 ?

'포스트맨으로 실제 동작을 확인하면 동작의 성공 여부를 알지 않나 ?' 라는 생각이 가장 크게 자리잡았다. 그래서 속시원하게 이해할 수 있는 정확한 답을 알고싶었다.

생각해보자

대부분 테스트 코드 목적은 동작 확인을 위함이라고 말한다. 이유는 뭘까 ?

우리는 구현한 로직에 대한 테스트를 위해 포스트맨과 같은 API툴을 사용해 확인하곤 한다. 근데 만약 개발자가 예상치 못한 변수를 찾아 처리하고 싶을 때 이런 변수들을 어떻게 발견할 수 있을까 ? 디버깅으로 하면 될까 ? 그렇다면 디버깅과 테스팅의 차이는 뭘까 ? 나는 이 과정에서 테스트 코드를 하는 이유를 깨달았다.

디버깅 vs 테스팅

두 과정 모두 소프트웨어 개발에서 결함을 찾아내고 수정하는 데에 있어 공통점이 있다. 하지만, 그 접근 방식과 목적에서 차이가 난다.

디버깅은 오류 수정을 위한 접근이고, 테스팅은 오류 예방을 위한 식별 접근이다.

  • 디버깅은 어떤 결함이 발생했을 때 그 원인을 찾고, 코드를 수정하는 활동이다. 디버깅을 하기 위해선 애플리케이션을 실행 시킨 후 실패한 코드에 대해 개발자가 수동으로 체크하며 확인해야 할 필요가 있다.
    → 정리하면, 디버깅은 이미 발생한 문제를 해결하기 위한 과정으로 문제의 원인을 찾아내고 수정하는 데 초점을 둔다.

  • 반면 테스팅은 결함을 발견하기 위한 활동으로, 오류 식별을 목적으로 한다. 테스팅을 하기 위해서 먼저 개발자가 예측되는 오류나 관련 코드를 작성한 후 기대한 결과값과 같은 값이 리턴되는지 확인한다.
    → 정리하면, 테스팅은 주로 예방적인 목적으로, 개발 초기 단계에서 문제를 사전에 찾아내 수정하는 데 중점을 둔다.

✅ 결국 테스트 코드는 예상치 못한 변수로 인해 동작이 실패를 방지하고자 경우의 수를 찾는 과정임을 알 수 있었다.

좋은 테스트는 뭘까

어떤 코드를 작성했을 때 유지 보수하는 것은 중요하고 당연하다. 단위 테스트도 예외는 아니다.

테스트 코드를 작성하다 보면 아래와 같은 상황을 맞이할 수 있다.

  • 어떤 가치도 증명하지 못하는 테스트
  • 실행하는 데 오래 걸리는 테스트
  • 코드를 충분히 커버하지 못하는 테스트
  • 구현과 강하게 결합되어 있어 작은 변화에도 쉽게 깨지는 테스트
  • 수많은 설정 고리로 점프하는 난해한 테스트
  • 테스트를 사용하는 사용자에게 어떤 정보도 주지 못하는 테스트

위와 같은 위험으로부터 벗어나기 위해 우리는 FIRST 원리를 알 필요가 있다. 이는 테스트를 보다 좋고 깨끗한 테스트 코드로 만들어 줄 수 있다.

📍 [F]ast

빠른 테스트를 유지하자 !

시스템이 커지면 단위 테스트도 실행하는 데 점점 오래 걸린다. 반면, 깨끗한 설계는 빠른 실행을 유지할 수 있다. 따라서 가장 먼저 느린 테스트에 대한 의존성을 줄여보자. 테스트 코드는 빠르게 동작하며, 느린 것에 의존하는 코드를 최소화한다면 작성하기 쉬워진다. 이러한 의존성을 최소화하는 것 역시 좋은 설계의 목표이다. 코드를 클린 객체 지향 설계 개념과 맞출수록 단위 테스트 작성도 쉬워진다.

📍 [I]solated

의존하지 말자 !

테스트 코드는 어떤 순서나 시간에 관계없이 실행할 수 있어야 한다. 각 테스트가 작은 양의 동작에만 집중하면 테스트 코드를 집중적이고 독립적으로 유지하기 쉬워진다. 객체 지향 클래스 설게의 단일 책임 원칙(SRP)에 따르면 클래스는 작고 단일한 목적을 가져야 한다. 따라서 테스트를 추가할 때마다 '테스트 이름으로 기술할 수 있는 어떤 동작을 대표하는지' 에 대해 고민할 필요가 있다.

📍 [R]epeatable

반복 가능하다 !

말 그대로 반복 가능한 테스트는 실행할 때마다 결과가 같아야 한다. 따라서 반복 가능한 테스트를 만들려면 직접 통제할 수 없는 외부 환경에 있는 항목들과 격리시켜야 한다. (여기서 외부 환경이란 실제 부하를 받는 환경이나 운영 DB 등을 말한다.) 하지만 불가피하게 통제할 수 없는 요소와 상호 작용해야 할 수 있다. 이때는 테스트 대상 코드의 나머지를 격리하고 독립성을 유지하는 방법으로 목 객체를 사용하면 된다.

📍 [S]elf-validating

스스로 검증이 가능하다 !

스스로 검증 가능하고 준비할 수도 있어야 한다. 테스트에 필요한 어떤 설정 단계든 자동화 해야 한다. 이는 소스 저장소의 변화를 감지하여 빌드와 테스트 절차를 진행하는 지속적 통합(CI)과 통합될 때마다 변경사항을 반영해 배포되는 지속적 배포(CD)를 말한다.

📍 [T]imely

적절한 시점에 작성하자 !

사실 언제라도 단위 테스트를 작성할 수 있다. 가능하면 적절한 순간에 단위 테스트를 작성하는 것이 좋다. 이때 적절한 순간은 프로젝트마다 다르겠지만 새로운 기능이나 로직이 개발될 때 바로 그와 관련된 테스트를 작성할 수 있다.


어떻게 작성해야 할까

테스트는 '최대한 못되게' 작성해야 한다.

테스트 코드는 개발자가 마음대로 목킹과 빈 주입을 통해 동작을 확인할 수 있다. 따라서 내가 구현한 동작 중에 발생할 예외처리를 제대로 처리했는가 등을 확인하기 위해선 의도적으로 테스트에 실패해야 한다. 사실 테스트 주도 개발을 (TDD, Test-Driven Development) 따르는 개발자들은 항상 테스트에서 먼저 실패해야 한다. 그리고 작성하는 코드는 테스트를 통과하도록 작성해야 하는데, 이 때문에 TDD 가 어려운 이유 중 하나이지 아닐까 싶다.

개발한 코드에서 잠재적으로 영향력이 큰 데이터 변형들을 고려해 볼 수도 있고, 또는 간단한 메서드에서도 작게는 수십 혹은 수백 개의 테스트 코드를 작성할 수 있다. 결국 if 문과 데이터 변형들을 고려해 결과를 예측하고, 실제 동작은 어떻게 이루어지는지 확인하는 게 목적임을 알아두면 의외로 테스트는 쉽게 작성할 수 있다.

예를 들어, 게시글(Board)에 대한 간단한 메서드를 테스트한다면 아래와 같은 상황을 고려할 수 있다.

  • Board 인스턴스에 Member 객체를 포함하지 않을 때
  • Board 인스턴스 생성값 중 null 값이 포함될 때
  • Board 인스턴스 생성 시 유효하지 않은 Member 객체가 접근할 때
  • Board 인스턴스 수정 시 board.getMember() 와 접근한 Member 객체가 일치하지 않을 때
  • Board 조회 시 board.getId() 가 존재하지 않을 때
  • 권한이 없는 Member 객체가 Board 를 삭제하려고 할 때

물론 위 고려 사항들은 구체화되지 않았기 때문에 기능별 로직 조건에 따라 좀 더 구체화가 필요하다. 테스트를 작성하고 나면 코드가 실제로 어떻게 동작하는지 더 잘 이해할 수 있을 것이다.

테스트 패턴

보통 given - when - then 으로 나누어 작성한다. 이렇게 나눠 작성하면 가독성이 정말 좋고 유지보수를 관리하기에도 편하다. 그래서 live template 으로 만들어두면 작성하면 편리하다.

@Test
void 테스트_성공() {

	// given -- 테스트의 상태 설정
    
    // when -- 테스트하고자 하는 행동
    
    // then -- 예상되는 변화 및 결과
}

테스트 코드는 레이어별로 작성되는 게 다르다.

✓ Repository Test

  • 목적: 데이터베이스와의 상호작용을 검증한다.
  • 방법: H2 등을 사용하여 실제 데이터베이스 환경을 모방한다.

※ 이때 임베디드 DB 가 아닌 자신이 설정한 또는 실제 DB 환경으로 테스트하고 싶을 경우 @AutoConfigureTestDatabase(replace = Replace.NONE) 를 꼭 설정해줘야 한다.

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)  // 자신이 설정한 DB로 사용할 경우에 넣어준다.
class BoardRepositoryTest {

	@Autowired
    BoardRepository boardRepository;
    
    @DisplayName("저장된 게시글을 조회한다.")
    @Test
	void 게시글_전체_조회() {
        
        //given
        final String title = "테스트 게시글";
        final String content = "테스트 본문";

        boardRepository.save(Board.builder()
                .title(title)
                .content(content)
                .author("test@gmail.com")
                .build());

        //when
        final List<Board> boards = boardRepository.findAll();

        //then
        final Board board = boards.get(0);
        
        assertThat(board.getTitle()).isEqualTo(title);
        assertThat(board.getContent()).isEqualTo(content);
    }
  • @DataJpaTest : JPA 관련 빈(Bean)과 @Transactional 어노테이션이 달려있어서 테스트가 끝나면 Configuration만 주입받아서 빠르게 테스트를 진행할 수 있다.
  • @Autowired : 필요한 의존 객체 타입에 해당하는 빈을 찾아 주입한다.
  • assertThat : JUnit의 테스트 코드에 사용되는 AssertJ 라이브러리는 예상한 결과값과 실제값을 비교할 때 사용된다.

✓ Service Test

  • 목적: 비즈니스 로직의 정확성을 검증한다.
  • 방법: Mockito 를 사용하여 의존하는 컴포넌트를 Mock 객체로 대체하고, 비즈니스 로직을 테스트한다.
@ExtendWith(MockitoExtension.class)
class BoardServiceTest {

	@InjectMocks
    private BoardService boardService;

    @Mock
    private BoardRepository boardRepository;

    @Mock
    private MemberRepository memberRepository;
    
    ... 
    
    @DisplayName("저장된 게시글을 조회한다.")
   	@Test
    void 게시글_전체_조회() {
    	...
    }

}
  • @InjectMocks : @Mock 으로 만들어진 인스턴스를 자동으로 주입해준다.
  • @Mock : 로직이 빈 껍데기로 생각하면 된다. 실제로 메서드는 갖고 있지만 내부 구현이 없는 상태이다.

✓ Controller Test

  • 목적: HTTP 요청과 응답 처리를 검증한다.
  • 방법: @WebMvcTest 를 사용하여 컨트롤러 레이어만을 대상으로 하는 테스트를 작성한다.
@WebMvcTest(controllers = BoardController.class)
class BoardControllerTest {
	
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private BoardService boardService;
    
    ...
    
    @DisplayName("저장된 게시글을 조회한다.")
    @Test
	void 게시글_전체_조회() {
		...
    }	
  • @MockBean : Mock 객체를 SpringContext 에 빈으로 등록한다. @Autowired 와 함께 사용된다.


참고자료
Unit testing vs integration testing
테스팅과 디버깅: 중요한 차이

profile
자라나라 개발개발 ~..₩

0개의 댓글