소프트웨어 개발에서 테스트는 반드시 필요하다.
하지만 매번 개발할 때마다 테스트를 수동으로 하게 되면 그에 드는 시간과 노력 비용이 상당히 크다.
또한 사람이 직접 하는 것이다 보니 테스트해야 할 부분을 놓치거나 틀린 부분을 발견하지 못하는 등의 실수가 나올 수 있다.
따라서 애플리케이션 프로젝트와 별개로 테스트 프로젝트를 구성하고 이를 통해 내가 테스트할 항목들을 미리 코드로 작성해놓으면 개발 비용도 줄고 협업이나 인수인계가 필요할 때에도 좋은 레퍼런스가 되어줄 것이다.
지금부터는 닷넷에서 가장 많이 사용하는 테스트 프레임워크들 중 하나인 xUnit 을 이용하여 간단한 테스트용 프로젝트를 만들어보고 테스트의 종류별 개념, 개발 규칙 등을 정리해보고자 한다.
포스트 내용은 대부분 MS Document에 있는 자료들을 참고하였으며 출처는 글 아래에 정리해놓았다.
.NET 환경에서 여러 테스트를 지원하는 xUnit 프레임워크, Mock 패키지를 이용하여 유닛 테스트, 통합 테스트( + 기능 테스트) 를 간단한 형태로 구성해보았다.
이번에 집중한 내용은
이며 따라서 애플리케이션 프로젝트는 최대한 간단한 형태의 Web API 로 구성했다.
단위 테스트
애플리케이션 논리의 단일 부분을 테스트한다.
종속성이나 인프라와 작동하는 방식은 테스트하지 않기 때문에 추가적으로 통합 테스트가 필요하다.
메모리와 프로세스에서 전적으로 실행되며 파일 시스템, 네트워크, 데이터베이스와 통신하지 않는다. 즉, 단위 테스트는 코드만 테스트한다.
통합 테스트
데이터베이스, 파일 시스템 등 인프라와 상호작용하는 과정을 테스트한다.
단위 테스트에 비해 속도가 더 느리고 설정도 어렵다.
따라서 단위 테스트로 테스트가 가능한 항목은 통합 테스트가 아니라 단위 테스트에서 하는 것이 좋다.
기능 테스트
통합 테스트가 개발자의 관점에서 시스템의 구성 요소가 모두 제대로 동작하는지 그 연결을 확인하는 과정이라면
기능 테스트는 사용자의 관점에서 시스템이 원하는 동작을 하는지에 대한 정확성을 확인하는 과정이다.
구현 내용을 보면 두 테스트는 비슷한 경향이 있지만 관점의 차이라고 해석할 수 있다.
아래 그림은 테스트 유형을 나타내는 피라미드인데 위로 갈수록 더 많은 모듈의 연결이 필요하고 더 적은 수를 가진다는 것을 알 수 있다.
올라갈수록 테스트를 구성하기 더 어렵고 많은 시간이 들어가기 때문이다.
유형별(단위 테스트, 통합 테스트, 기능 테스트), 기능별(프로젝트, 네임스페이스)로 구분하는 것이 좋다.
테스트 프로젝트의 구성은 src 폴더 하위에 애플리케이션 프로젝트들을 구성하고
test 폴더 하위에 테스트 프로젝트를 테스트 유형별로 나눈 뒤 각 유형 내에 애플리케이션 별 테스트 프로젝트로 디렉토리를 구성하는 것이 좋은 방법이다.
테스트 이름은 각 테스트의 기능을 정확히 나타낼 수 있도록 일관된 방식으로 지정한다.
좋은 방법으로는 테스트 클래스의 이름을 테스트 할 클래스와 메서드 이름의 조합으로 만드는 것이다. 이 과정에서 많은 소규모 테스트 클래스가 만들어지지만 각 테스트의 역할이 명확해진다. 그리고 테스트 메서드의 이름은 예상되는 동작, 주어지는 입력과 가정을 포함한다.
예시
CatalogControllerGetImage.CallsImageServiceWithId
CatalogControllerGetImage.LogsWarningGivenImageMissingException
CatalogControllerGetImage.ReturnsFileResultWithBytesGivenSuccess
CatalogControllerGetImage.ReturnsNotFoundResultGivenImageMissingException
애플리케이션은 MVC 모델을 기반으로 한 간단한 웹 API이며 주어진 영화 리스트에서 랜덤으로 영화를 추천해주는 내용의 서비스이다.
구성 요소들을 차례대로 살펴보자.
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using ProjectForTest.Models;
using ProjectForTest.Service;
namespace ProjectForTest.Controllers
{
[Route("api/[controller]")]
public class MoviesController : Controller
{
private readonly IFilmSpecialist _filmSpecialist;
private readonly ILogger<HomeController> _logger;
public MoviesController(ILogger<HomeController> logger, IFilmSpecialist filmSpecialist)
{
_logger = logger;
_filmSpecialist = filmSpecialist;
}
[HttpGet("getMovie")]
public Movie GetMovie()
{
_logger.Log(LogLevel.Information, "Ask the film specialist.");
var movie = _filmSpecialist.SuggestMovie();
_logger.Log(LogLevel.Information, $"Suggested movie : {movie}");
return movie;
}
}
}
namespace ProjectForTest.Models
{
public class Movie
{
public string Title { get; }
public string Release { get; }
public string[] Genres { get; }
public string Duration { get; }
public Movie(string title, string release, string[] genres, string duration)
{
Title = title;
Release = release;
Genres = genres;
Duration = duration;
}
}
}
using ProjectForTest.Models;
using System;
namespace ProjectForTest.Service
{
public class FilmSpecialist : IFilmSpecialist
{
private static readonly Movie[] Films =
{
new Movie("RoboCop", "10/08/1987", new[] {"Action", "Thriller", "Science Fiction"}, "1h 42m"),
new Movie("The Matrix", "05/21/1999", new[] {"Action", "Science Fiction"}, "2h 16m"),
new Movie("Soul", "12/25/2020", new[] {"Family", "Animation", "Comedy", "Drama", "Music", "Fantasy"}, "1h 41m"),
new Movie("Space Jam", "12/25/1996", new[] {"Adventure", "Animation", "Comedy", "Family"}, "1h 28m"),
new Movie("Aladdin", "07/03/1993", new[] {"Animation", "Family", "Adventure", "Fantasy", "Romance"}, "1h 28m"),
new Movie("The World of Dragon Ball Z", "01/21/2000", new[] {"Action"}, "20m")
};
public Movie SuggestMovie()
{
Random random = new Random();
var filmIndexThatIWillSuggest = random.Next(0, Films.Length);
return Films[filmIndexThatIWillSuggest];
}
}
}
서비스 계층인 FilmSpecialist 클래스의 동작을 테스트하기 위한 유닛 테스트 코드이며 xUnit과 FluentAssertions Nuget 패키지를 이용하여 구성되었다. 이 때 FluentAssertions는 Assert 조건과 관련된 여러 인터페이스를 제공하여 쉽고 직관적이게 테스트를 진행할 수 있게 해준다.
using FluentAssertions;
using ProjectForTest.Service;
using Xunit;
namespace TestProject
{
public class FilmSpecialistSuggestMovie
{
private readonly IFilmSpecialist _filmSpecialist = new FilmSpecialist();
[Fact]
public void ReturnRandomMovieWhenAsked()
{
var suggestedMovie = _filmSpecialist.SuggestMovie();
var expectedTitles = new string[]
{
"RoboCop", "The Matrix", "Soul", "Space Jam", "Aladdin", "The World of Dragon Ball Z"
};
suggestedMovie.Title.Should().BeOneOf(expectedTitles);
}
}
}
통합 테스트와 기능 테스트는 원래 그 목적과 내용이 다르지만 이번 프로젝트는 워낙 규모가 작은 테스트용이다 보니 두 테스트를 나누는 의미가 별로 없다고 판단되어 하나로 구성했다.
내용으로는 Http 요청을 통한 컨트롤러와의 네트워크 연결부터 시작되어 최종적인 결과가 원하는 동작이 맞는지 확인하는 테스트를 구성했다.
통합 테스트에서는 WebApplicationFacotyr 를 이용하여 실제 웹 어플리케이션이 실행될 때와 같은 환경을 구성할 수 있도록 하였으며 Mock 객체를 이용하여 IFilmSpecialist에 대한 종속성을 주입하였다. Mock 개념에 대한 자세한 내용은 이후에 다른 포스트에서 진행할 생각이다.
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Moq;
using ProjectForTest;
using ProjectForTest.Models;
using ProjectForTest.Service;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Xunit;
namespace FunctionalTests.ProjectForTest
{
public class MoviesControllerGetMovie : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly IFilmSpecialist _filmSpecialist;
private HttpClient _httpClient;
public MoviesControllerGetMovie(WebApplicationFactory<Startup> factory)
{
_filmSpecialist = Mock.Of<IFilmSpecialist>();
_httpClient = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IFilmSpecialist>();
services.TryAddTransient(_ => _filmSpecialist);
});
}).CreateClient();
}
[Fact]
public async Task CreateGameGivenFirstMovementIsBeingExecuted()
{
var requestPath = "/api/movies/getMovie";
var movieToBeSuggested = new Movie("Schindler's List", "12/31/1993", new[] { "Drama", "History", "War" }, "3h 15m");
Mock.Get(_filmSpecialist)
.Setup(f => f.SuggestMovie())
.Returns(movieToBeSuggested)
.Verifiable();
var response = await _httpClient.GetAsync(requestPath);
response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var movie = await response.Content.ReadFromJsonAsync<Movie>();
movie.Should().BeEquivalentTo(movieToBeSuggested);
Mock.Get(_filmSpecialist).Verify();
}
}
}
xUnit은 [Fact] 라는 Attribute를 붙이면 자동으로 테스트 내용으로 인식되어 탐색기에 나타나게 된다.
코드를 구성한 뒤 테스트 프로젝트를 실행시키면 다음과 같이 모두 성공했음을 알리게 된다.
처음 테스트 유형별 개념 설명에서 봤듯이 통합 테스트가 유닛 테스트에 비해 훨씬 오래 걸린다는 것을 알 수 있다.
그럼 만약 테스트가 실패하면 어떻게 될까?
통합 테스트 코드 중 예상되는 Response 의 HttpStatusCode를 OK가 아닌 다른 것으로 일부러 틀리게 변경한 뒤 다시 테스트를 진행해보았다.
이처럼 실패한 테스트와 원인을 알려준다.
만약 테스트가 실패하면 이 내용을 바탕으로 분석하고 코드를 수정할 수 있다.
닷넷의 대표적인 테스트 프레임워크인 xUnit FrameWork 를 사용하여 테스트 프로젝트를 구성해보았다.
또한 실무나 대규모 프로젝트에서 사용될 수 있도록 명확한 역할을 나타내거나 공간을 분리하는 여러 규칙들을 배웠다.
이를 기반으로 다음 프로젝트에서는 방대해진 비즈니스 로직을 위한 테스트들을 더 강화하고 더 나아가 AWS CodePipeline 과 같은 CI/CD 파이프라인 과정에서 테스트 자동화까지 구성할 수 있을 것으로 기대한다.
닷넷환경에서 테스트 코드 작성할 일이 있어서 잘 봤어요! 감사합니다