PR

배운 것들

테스트 코드에도 관심사에 따라 도메인을 분리하자

이전 로또 미션에서 준일님께서 남겨주신 핵심적인 피드백은 ‘도메인 분리’ 였다. 코드를 관심사에 따라 여러 도메인으로 분리하고, 각 도메인은 자신의 책임과 역할을 명확히 수행하도록 하는 것. 결국 테스트 코드도 결국 ‘코드’이기 때문에 이러한 룰이 동일하게 작용한다고 생각했다.

특히, 내가 느끼기로 비동기 코드의 테스트 코드를 작성하는 과정에서 필요한 일차적 관심사 분리는 1) API 동작 여부 확인과 2) 기능 검사에 대한 분리이다. 비동기 코드에서 API 의 응답에 의존하는 경우가 대다수일 수 밖에 없을 것 같은데, 이 둘을 어떻게 분리할 수 있을까?

아니, 그 전에 왜 분리해야할까? 내 생각은 이렇다.

  1. 문제의 원인을 명확히 파악하기 위해서다.

만약 아래의 비동기 코드에 대한 테스트 코드가 실패했다고 가정해보자. 그리고 테스트 코드는 현재 실제 API 를 호출하고 있다.

// 실패한 테스트 코드
describe("앱 기능 테스트", async () => {
  it("첫번째 페이지의 영화 목록을 모두 불러온 상태일때 두번째 페이지의 영화 목록을 불러오면, currentPage와 movies 가 갱신된다.", async () => {
    const nextPage = 2
    const app = new App()
    const movieList = new MovieList()
 
    // 실제 api 호출
    await app.fetchNextPage(movieList)
 
    expect(app.currentPage).to.equal(nextPage)
    expect(movieList.movies).to.have.length(Api.NUM_MOVIES_PER_PAGE * nextPage)
  })
})

일단, 가장 먼저 의심되는 부분은 await app.fetchNextPage(movieList); 이다. 어떠한 이유로 인해 API 요청이 실패하면, 뒤에 있는 코드들 또한 제대로 수행되지 않았을 것이니 말이다. Cypress 의 log 를 찍어보던, 아니면 Postman 에서 API 를 직접 호출해보며 API 의 작동 여부를 확인할 것이다. 그 결과 API 의 문제가 아니라는 것이 밝혀지면, 그제서야 도메인 로직에 어떤 문제가 있는지 살펴보기 시작할 것이다. 굉장히 비효율적이다.

이를 해결하기 위해서 테스트 코드를 이렇게 나눠보자.

  • API 가 정상적으로 작동하는지를 테스트하는 코드
  • API 가 정상 작동한다는 가정하에 기능만을 테스트하는 코드

만약 두번째 테스트 코드가 실패한다면 이전에 API 작동여부를 확인할 필요없이 바로 비즈니스 로직을 수정할 수 있을 것이다. 만약 API 에 문제가 발생했다면 첫번째 테스트 코드가 실패했을테니…

이제 테스트 코드를 분리해보자. Cypress 의 Fixture 를 사용하여 API 요청을 목업할 수 있다.

// 1. API 작동 테스트
 
describe("Api 기능 테스트", () => {
  it("영화 목록 API를 호출하면 20개씩 목록에 나타나야 한다.", () => {
    const currentPage = 1
    const endpoint = Api.generatePopularMoviesUrl(currentPage)
 
    cy.requestApi("GET", endpoint).as("getMovies")
 
    cy.get("@getMovies").its("status").should("eq", 200)
    cy.get("@getMovies").its("body.results").should("have.length", Api.NUM_MOVIES_PER_PAGE)
  })
})
// 2. 기능 테스트
 
describe("앱 기능 테스트", async () => {
it("첫번째 페이지의 영화 목록을 모두 불러온 상태일때 두번째 페이지의 영화 목록을 불러오면, currentPage와 movies 가 갱신된다.", () => {
 
	const app = new App();
	const movieList = new MovieListModel();
 
 
	cy.interceptGetPopularMovies(2); // API 요청을 intercept 하여 fixture 를 응답
 
	const nextPage = 2;
 
	cy.wrap(app.fetchNextPage(movieList)).then(() => {
		expect(app.currentPage).to.equal(nextPage);
 
		cy.fixture("movieListPage1.json").then(({ results: page1 }) => {
			cy.fixture("movieListPage2.json").then(({ results: page2 }) => {
				const totalMovies = page1.length + page2.length;
				expect(movieList.movies).to.have.length(totalMovies);
			})
		})
	})
});
  1. 서버가 어떤 응답을 내려줄지 모른다.

두번째 이유는 서버가 어떤 응답을 내려줄지 클라이언트 입장에서는 전혀 예측할 수가 없다. Cypress 는 기본적으로 assertion 을 통해 테스트 코드의 작동 여부를 판단한다.

const name = await fetchUserNmae()
 
expect(name).to.equal("Jane")

위와 같이 name 을 API 를 통해 가져온다고 할때 서버가 어떤 응답값을 내려줄지 알고 비교를 할 수 있겠는가? 그나마 GET 요청일 경우는 서버와의 합의하에 해결할 수 있다쳐도, POST 요청일 경우는 답이 없다.

const count = await increaseCount() // count 값을 1씩 증가시키는 POST 요청
 
expect(count).to.equal(???)
 

만약 위와 같이 count 를 증가시키는 POST 요청이 있을 경우, 부른 횟수에 따라 count 값이 변경될 것이고 이때는 count 값이 무엇이 될지 아무도 모른다. 따라서, 이러한 문제들을 해결하기 위해 Cypress 의 Fixture 를 사용하면, 클라이언트에게 일관된 응답을 제공하여 예측 가능한 테스트 환경을 만들 수 있다.

도메인 객체 만들기

지금까지는 API 요청을 통해 받은 JSON 을 그대로 렌더링 해주었다. 도메인 객체는 JSON 을 그대로 사용하는 것이 아니라 클라이언트가 객체로 한번 랩핑하여 사용하는 것을 말한다. 그럼 왜 굳이 랩핑을 할까?

교안에서 제공하는 이유는 이렇다.

이번 미션에서는 TMDB API 라는 무료 Open API 를 사용하였는데, 만약 이 API 가 유료로 변경되어 다른 API 를 사용해야한다면? 같은 영화 리스트에 대한 응답을 받더라도 key 값이나 value 의 형태가 달라질 것이므로, 기존의 key, value 에 의존하고 있던 코드들을 전부다 수정해야할 것이다.

// TMDB API
[
  {
    "name": "킹콩과 고릴라",
    "rating": 3.0,
    "director": "스티븐 스필버그",
    "description": "킹콩과 고릴라가 싸우는 영화이다."
  }
]
// Netflix API
[
  {
    "title": "킹콩과 고릴라",
    "rating_score": 3.0,
    "director": "스티븐 스필버그",
    "des": "킹콩과 고릴라가 싸우는 영화이다."
  }
]

따라서, Movie 라는 객체를 정의하고 여기에 응답값을 바인딩하여 관리하는 것이다.

class MovieModel {
  #title: string
  #rating: number
  #director: string
  #overview: string
 
  constructor({
    title,
    rating,
    director,
    overview,
  }: {
    title: string
    rating: number
    director: string
    overview: string
  }) {
    this.#title = title
    this.#rating = rating
    this.#director = thumbnail
    this.#overview = overview
  }
}
// 도메인 객체에 응답값을 바인딩
const addMovies = (movies: PopularMovieListResponseDTO[]) => {
  movies.forEach((movie) => {
    this.addMovie(
      new MovieModel({
        title: movie.title,
        director: movie.director,
        rating: movie.rating,
        overview: movie.desc,
      }),
    )
  })
}
 
const movies = await Api.searchMovies(query, page)
 
if (!movies) {
  return
}
 
this.addMovies(movies)

이렇게 될 경우 나중에 다른 API 를 사용하더라도 다른 코드를 건드릴 필요 없이 위 바인딩 로직에 사용되는 key, value 만 변경하면 된다.

또한, 내가 생각하는 큰 장점은 value 값을 내가 원하는 방식으로 커스텀하여 갖고 있을 수 있는 것이다. 예를 들어, 화면에 영화의 장르들을 보여줘야 하는 상황이 주어졌다.

이때, TMDB API 는 장르에 대한 응답을 다음과 같이 제공한다.

"genres": [
	{
		"id": 878,
		"name": "SF"
	},
	{
		"id": 12,
		"name": "모험"
	},
	{
		"id": 28,
		"name": "액션"
	}
]

나는 장르의 id 에는 전혀 관심이 없고, 오로지 name 에만 관심이 있다.

class MovieModel {
  genres: string[] = []
}
this.genres = genres.map((genre) => genre.name)

따라서, 위와 같이 도메인 객체를 정의하고 genres 에는 영화의 이름들만 맵핑하여 저장하도록 하였다.

다만, 당장 도메인 객체를 실무에 적용할 수 있을지 아직까지는 모르겠다. 일단, NEXTSTEP 에서는 객체지향 프로그래밍을 하였고, 그러한 관점에서 도메인 객체는 굉장히 자연스러운 과정이었다. (사실 도메인 객체라는 네이밍만 몰랐을 뿐이지, 구현해놓고 내가 만든게 도메인 객체라는 거구나 알았다.) 실무에서는 객체 지향으로 프로그래밍을 하고 있지는 않아서, 도메인 객체를 적용하기 위해 별도의 클래스를 만드는 것이 과연 맞는건가라는 생각이 들었다. 이 부분은 내가 맞게 이해하고 있는지 확인이 필요하다.

오류처리 잘하기

오류 처리를 어떻게 하면 잘할 수 있을까? 결국 이것도 관심사 분리와 연결되어 있다.

// Api.ts
 
async getMovies(page: number) {
 
	const movieUrl = Api.generatePopularMoviesUrl(page);
 
	try {
 
		const { results: movies } = await ApiClient.get<{
		results: PopularMovieListResponseDTO[];
		}>(movieUrl);
 
		return movies;
 
	} catch (e) {
 
		if (typeof e === "number") {
			switch (e) {
				case 401:
					alert("UNAUTHORIZED USER")
 
				case 404:
					alert("NOT VALID URL")
 
				case 500:
					alert("INTERNAL SERVER ERR")
			}
		}
 
		throw e;
	}
}

getMovies 함수는 영화 리스트 API 를 호출하고 서버의 응답/ 오류를 전달만 하면 제 몫을 다하는 함수다. 하지만, 위의 코드는 오류가 발생하였을 때 alert 함수를 통해 사용자에게 오류 메시지를 보여주고 있다. alert 함수는 엄연히 view 와 관련된 로직이기 때문에 이것은 관심사 분리 원칙에 어긋난다.

만약, 기본 alert 창이 아닌 토스트나 팝업 등 커스텀하게 오류 메시지를 사용자에게 보여주고 싶은 경우 위 코드는 무용지물이 된다. 따라서, 이 로직에서는 에러를 throw 하여 상위 함수로 전달하고, 어떻게 보여줄지는 view 를 담당하는 로직에서 처리하도록 하자.

switch (e) {
  case 401:
    throw Error(ErrorMessage.UNAUTHORIZED)
 
  case 404:
    throw Error(ErrorMessage.NOT_VALID_URL)
 
  case 500:
    throw Error(ErrorMessage.INTERNAL_SERVER_ERROR)
 
  default:
    throw Error(ErrorMessage.DEFAULT)
}

Cypress 와 친해지기

Jest 를 비롯해 Cypress 와 같은 테스팅 라이브러리를 NEXTSTEP 에서 처음 사용하였는데, 초반에는 기본적인 기능만 사용하다가 필요한 것을 찾아보면서 조금씩 공부해보았다. 그 중 몇 가지 기억에 남는 것만 끄적여 보자면…

  1. Commands 사용하기 결국 테스트 코드도 ‘코드’ 이기 때문에 관리해야 할 대상이다. 따라서, 우리가 평소에 여러번 중복되는 코드들은 별도의 함수로 만들어 관리하듯, 테스트 코드도 마찬가지이다. 이를 위해 Cypress 가 제공하는 기능이 바로 Commands 이다. Commands 에 자주 사용하는 테스트 코드를 등록하면 함수를 갖다 쓰듯 사용할 수 있다.

나의 경우 API 요청을 intercept 하고, fixture 를 응답으로 내려주는 로직이 상당히 많이 중복되었는데, 따라서 이들을 Commands 에 등록하였다.

// cypress/support/commands.ts
 
Cypress.Commands.add("interceptGetPopularMovies", (page: Page) => {
  return cy.interceptRequest("GET", Api.generatePopularMoviesUrl(page), {
    delay: 2000,
    fixture: `movieListPage${page}.json`,
  })
})
// cypress/App.cy.ts
 
describe("앱 기능 테스트", async () => {
  beforeEach(() => {
    cy.interceptGetPopularMovies(1)
  })
})

매직 넘버 피하기

스텝들을 진행하면서 느낀 점은 좋은 코드란 생각보다 그렇게 멀리있지 않다고 느꼈다. 특히 매우 기본적인 것이지만, 귀찮다는 이유로 잘 지키지 않고 있던 것들이 있는데, 이러한 부분부터 챙기고 가면 어떨까. 그 중 내가 가장 많이 들었던 피드백은 ‘매직 넘버 없애기’ 이다. 참고로, 매직 넘버란 “코드 상에서 의미를 알 수 없는 문자나 숫자” 를 일컫는 말이다.

const movie = new Movie({
  title: movie.title,
  thumbnail: "https://image.tmdb.org/t/p/w500" + movie.poster_path,
  rating: movie.vote_average,
})

위와 같은 코드가 있을 때, https://image.tmdb.org/t/p/w500 가 base URL 인지, 썸네일 주소 인지 알 수가 없다. (만약 이 둘이 다를 수도 있으니) 만약 위 url 이 다른 곳에도 쓰이고 있고, 어느날 썸네일 주소가 바뀐다면? 일일이 찾아다니면서 바꿔야하는 번거로움을 감수해야한다.

그래서, 깔끔하게 위 URL 을 상수로 만들면 끝이다.

const THUMBNAIL_URL = "https://image.tmdb.org/t/p/w500"
 
new Movie({
  title: movie.title,
  thumbnail: `${THUMBNAIL_URL}${movie.poster_path}`,
  rating: movie.vote_average,
})