본문 바로가기

Tech

테스트가 의도한 코드를 실제로 검증하고 있는가

TL; DR

  1. Windows Docker Desktop app은 포트와 소켓을 열어주도록

      설정해야 테스트가 가능하다.

  2. 의도한 대로 테스트가 잘 진행되는가 더블 체크가 필요하다.

 

--

 

TDD 진행을 하면 테스트 환경을 세팅하는 것 자체가 걸림돌이 되는 경우가 있다.


특히 통합 테스트에서 실제 DB를 사용하려고 Testcontainers를 도입하면

Windows 환경에서 예상치 못한 문제를 만나기도 하고,
테스트가 초록불로 통과했는데 정작 검증하려던 코드가 한 번도

실행되지 않았다는 걸 나중에 깨닫기도 한다.

 

본문에서는 그 두 가지 상황을 소개한다.


Windows에서 Testcontainers가 Docker를 못 찾는 문제

Testcontainers는 테스트 실행 시 Docker 컨테이너를 직접 띄워서 실제 DB 환경을 만들어주는 라이브러리다.


H2 같은 인메모리 DB 대신 실제 MySQL을 쓸 수 있어서, 프로덕션 환경과 동일한 조건에서 통합 테스트를 돌릴 수 있다는 게 장점이다.

 

그런데 Windows 환경에서 처음 실행하면 이런 에러가 나온다.

Could not find a valid Docker environment.

Docker Desktop은 멀쩡히 켜져 있는데 Testcontainers가 Docker를 못 찾는다.

 

Demon 설정도 되어 있는데 말이다.

테스트를 위한 Docker 2375 port 오픈 설정


원인은 운영체제마다 Docker에 연결하는 방식이 다르다는 데 있다.

 

Testcontainers가 기본으로 잡는 Docker 소켓 경로는 unix:///var/run/docker.sock인데,

이건 macOS나 Linux 경로다.


Windows Docker Desktop은 named pipe 방식을 쓰기 때문에 경로 자체가 다르다.

 

H2 인메모리 DB로 바꾸면 Docker 없이 바로 해결되긴 한다.


하지만 H2는 MySQL이 아니다 보니, H2에서 통과한 테스트가 실제 환경에서 틀어지는 상황이 생길 수 있다.


그래서 Testcontainers를 그대로 쓰되 build.gradle.kts에 환경 변수를 직접 잡아줬다.

tasks.test {
    environment("DOCKER_HOST", "npipe:////./pipe/dockerDesktopLinuxEngine")
    environment("DOCKER_API_VERSION", "1.40")
    environment("TESTCONTAINERS_RYUK_DISABLED", "true")
}

 

DOCKER_HOST는 Windows Docker Desktop이 쓰는 named pipe 경로를 명시한 것이고,
RYUK_DISABLED는 테스트 종료 후 컨테이너를 자동 정리해 주는 Ryuk이라는 컴포넌트가

Windows에서 권한 문제를 일으킬 수 있어서 꺼둔 것이다.

 

이렇게 하고 나니 MySQL 컨테이너가 정상적으로 뜨고 통합 테스트가 돌았다.


다만 npipe 경로는 Windows 전용이라, 공용 사용 시 macOS나 Linux 환경이 섞여 있다면 OS를 감지해서 분기하거나 로컬 설정으로 빼는 게 더 낫겠다는 생각은 남아 있다.


E2E 테스트가 Facade를 검증하는 척했던 문제

GET /api/v1/users/me는 인증이 필요한 API다.


이 프로젝트에서는 Spring Security 대신 HandlerMethodArgumentResolver를 직접 구현해서 인증을 처리한다.


@CurrentUser 어노테이션이 붙은 파라미터가 있으면,

Spring MVC가 컨트롤러를 호출하기 전에 LoginUserResolver를 실행해 헤더에서 인증 정보를 꺼내 검증한다.

// LoginUserResolver.java
@Override
public Object resolveArgument(...) {
    String loginId = webRequest.getHeader("X-Loopers-LoginId");
    String loginPw = webRequest.getHeader("X-Loopers-LoginPw");

    UserModel user = userService.findByLoginId(loginId)
        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));

    if (!passwordEncoder.matches(loginPw, user.getPassword())) {
        throw new CoreException(ErrorType.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
    }

    return new LoginUser(user.getId(), user.getLoginId());
}

 

그리고 Facade에도 사용자가 없을 때를 대비한 코드가 있다.

// UserFacade.java
public UserInfo getMyInfo(String loginId) {
    return userService.findByLoginId(loginId)
        .map(UserInfo::from)
        .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
}

 

존재하지 않는 사용자로 GET /api/v1/users/me를 요청하면 404가 와야 한다는 E2E 테스트를 작성했고, 테스트는 통과했다.

 

코드도 있고 테스트도 통과하니 문제없어 보였다.

 

그런데 실제로 무슨 일이 일어나고 있는지 따라가 보면 이렇다.

요청 → LoginUserResolver
         └─ DB 조회 → 사용자 없음 → NOT_FOUND 예외 → 404

컨트롤러, Facade는 아예 호출되지 않음

 

테스트 케이스 요청 시퀀스 다이어그램(https://mermaid.live)

 

E2E 테스트에서 받은 404는 Facade의 orElseThrow가 아니라 LoginUserResolver가 던진 것이었다.
Facade의 NOT_FOUND 경로는 한 번도 실행된 적이 없었던 것이다.

 

Facade의 orElseThrow를 그냥 지울까 하는 생각도 했다.
현재 HTTP 흐름에서는 도달 자체가 불가능한 코드이니까.

 

그런데 Facade가 꼭 HTTP 요청을 통해서만 호출된다는 보장은 없다.
배치나 내부 로직에서 직접 호출될 수도 있고, 나중에 LoginUserResolver의 동작이 바뀔 수도 있다.

 

Facade가 호출자를 신뢰하기보다 스스로 방어하는 구조가 낫다고 판단해서 코드는 남겨뒀다.


대신 이 경로를 실제로 검증하는 단위 테스트를 따로 추가했다.

// UserFacadeTest.java
// E2E에서 LoginUserResolver가 먼저 차단하기 때문에
// Facade의 NOT_FOUND 경로가 도달되지 않는 갭을 보완하기 위한 테스트
@Test
void throwsNotFound_whenUserDoesNotExist() {
    // arrange
    given(userService.findByLoginId("nonexistent")).willReturn(Optional.empty());

    // act
    CoreException result = assertThrows(CoreException.class, () ->
        userFacade.getMyInfo("nonexistent")
    );

    // assert
    assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND);
}

 

UserService를 Mock으로 교체해서 DB도, Resolver도 거치지 않고 Facade 로직만 직접 실행한다.
이렇게 하면 orElseThrow가 실제로 동작하고 있다는 걸 확인할 수 있다.


 

위 경험을 겪고 나서 테스트를 작성할 때 계속 이 질문을 하게 됐다.

 

테스트가 통과하는 이유가, 내가 검증하려는 코드 때문이 맞나?

기댓값과 실제값이 일치하는 게 전부가 아니라, 그 값이 어떤 경로를 타고 나온 것인지까지 확인하는 것.

 

이게 TDD에서 생각보다 중요한 부분이었다.