Microsoft.Owin.Testing 패키지를 이용한 인메모리 통합 테스트

단위 테스트 만큼이나 통합 테스트는 필요하며 통합 테스트에 있어서도 자동화는 중요합니다. 단위 테스트를 통과한 모듈도 서로 조합되면 운영 환경에서 오류를 발생시킬 가능성이 존재하며 다양한 이유로 인해 단위 테스트가 어려운 코드도 있죠. GUI를 가진 응용프로그램을 인력 자원을 동원해 하나하나 통합 테스트하거나 그것조차 제대로 하지 않는 것이 국내 사정이지만 CUIT(Code UI Tests)나 Jasmine 등의 E2E(End-to-End) 도구를 사용해 통합 테스트를 자동화하면 테스트 비용과 테스트 오류 가능성을 낮출 수 있습니다.

API 역시 통합 테스트 대상입니다. API는 GUI 기반 응용프로그램과 비교할 때 통합 테스트를 수동으로 진행하기에 귀찮고 어려운 반면 자동화하기는 더 쉽습니다. Jasmine을 이용한 API 통합 테스트 자동화를 고려해 볼 수도 있지만 Visual Studio에 의해 관리되고 있는 기존의 테스트가 있다면 동일한 테스트 도구를 통해 관리하는 것이 훨씬 좋겠죠.

Microsoft.Owin.Testing 패키지를 사용하면 OWIN(Open Web Interface for .NET) 기반 ASP.NET Web API에 대한 통합 테스트를 쉽게 작성할 수 있습니다. 테스트는 메모리를 통해 이루어지기 때문에 네트워크 트래픽이 발생하지 않아 매우 효율적이면서도 ASP.NET Web API의 전체 파이프라인을 관통합니다. 뿐만 아니라 IoC 컨테이너를 조작하면 컨트롤러 이후의 과정을 모의화하는 것도 가능합니다.

프로젝트 초기화

Microsoft.Owin.Testing 패키지를 사용한 통합 테스트를 실습하기 위해 간단한 API 응용프로그램을 초기화합니다. 이름이 ContactManager인 콘솔 응용프로그램 프로젝트를 추가 하겠습니다.

System.ComponentModel.DataAnnotations 참조를 추가하고 패키지 관리자 콘솔을 사용해 Newtonsoft.Json 패키지와 Microsoft.AspNet.WebApi.OwinSelfHost 패키지를 설치합니다.

PM> Install-Package Newtonsoft.Json
PM> Install-Package Microsoft.AspNet.WebApi.OwinSelfHost

원래는 Microsof.AspNet.WebApi.OwinSelfHost 패키지가 Newtonsoft.Json 패키지에 의존되지만 현재 패키지 관리자 콘솔을 사용할 경우 미리 설치해주지 않으면 오류가 발생합니다. 곧 수정되겠죠.

초기화된 프로그램의 전체 코드는 다음과 같습니다.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Web.Http;
using System.Web.Http.Description;
using Microsoft.Owin.Hosting;
using Owin;

namespace ContactManager
{
    public class Startup
    {
        public void Configuration(IAppBuilder app)
        {
            HttpConfiguration config = new HttpConfiguration();
            config.MapHttpAttributeRoutes();
            app.UseWebApi(config);
        }
    }

    public class Program
    {
        static void Main(string[] args)
        {
            var url = "http://localhost:3000";
            using (WebApp.Start<Startup>(url))
            {
                Console.WriteLine("Listening on {0}...", url);
                Console.Write("Press [Enter] to quit.");
                Console.ReadLine();
            }
        }
    }

    public class Contact
    {
        public int Id { get; set; }
        [Required]
        public string FirstName { get; set; }
        [Required]
        public string LastName { get; set; }
        [Required]
        public string Email { get; set; }
    }

    [RoutePrefix("api/Contacts")]
    public class ContactsController : ApiController
    {
        static int _nextId = 0;
        static ConcurrentDictionary<int, Contact> _contacts = new ConcurrentDictionary<int, Contact>();

        static ContactsController()
        {
            Add(new Contact { FirstName = "Tony", LastName = "Stark", Email = "ironman@avengers.com" });
            Add(new Contact { FirstName = "Bruce", LastName = "Banner", Email = "hulk@avengers.com" });
            Add(new Contact { FirstName = "Thor", LastName = "Odinson", Email = "thor@avengers.com" });
        }

        static int GetNextId()
        {
            return Interlocked.Increment(ref _nextId);
        }

        static void Add(Contact contact)
        {
            int id = GetNextId();
            contact.Id = id;
            _contacts.TryAdd(id, contact);
        }

        [Route]
        public IEnumerable<Contact> Get()
        {
            return _contacts.Values.ToList();
        }

        [Route, ResponseType(typeof(Contact))]
        public IHttpActionResult Post(Contact contact)
        {
            Add(contact);
            return Ok(contact);
        }
    }
}

연락처를 추가하고 연락처 목록을 조회하는 API를 제공합니다. 응용프로그램을 실행하고 Fiddler 등으로 아래 요청을 테스트해보면 동작하는 것을 확인할 수 있습니다.

  1. GET
    GET http://localhost:3000/api/Contacts HTTP/1.1
    Host: localhost:3000
    
  2. POST
    POST http://localhost:3000/api/Contacts HTTP/1.1
    Host: localhost:3000
    Content-Type: application/json
    Content-Length: 78
    
    { "firstName": "Bruce", lastName: "Wayne", email: "batman@justiceleague.com" }
    

컨트롤러 단위 테스트의 한계

API 컨트롤러는 단위 테스트가 가능합니다. 하지만 컨트롤러 단위 테스트는 다음과 같은 한계점을 가지고 있습니다.

  1. ModelState 속성을 사용한 논리를 테스트할 수 없습니다. 요청 모델에 대한 상태 검사가 컨트롤러 코드에 의해 수행되지 않기 때문에 ModelStateDictionary.IsValid 속성은 항상 true를 반환합니다.

    물론 여기에서 설명된 것처럼 단위 테스트 코드에서 직접 처리하는 방법도 있지만 개인적으로 맘에 들지 않습니다.

  2. 필터 등의 특성을 사용한 선언적 코드를 테스트할 수 없습니다.

이와 같은 단위 테스트의 한계는 통합 테스트를 통해 극복할 수 있습니다.

통합 테스트 주도적 기능 추가

초기화된 코드를 살펴보면 Contact 모델 클래스는 FirstName, LastName, Email 속성을 RequiredAttribute 특성을 사용해 필수 속성으로 지정하고 있지만 여기에 대한 요청 데이터 검사는 전혀 이뤄지지 않습니다. POST 요청에서 필수 속성이 지정되었는지 검사하고 지정되지 않은 필수 속성이 발견되면 400(Bad Request) 상태 코드를 응답하도록 수정하겠습니다.

테스트 작성

간단하지만 이 포스트의 가장 중요한 내용입니다.

이름이 ContactManager.Tests인 테스트 프로젝트를 만들어 ContactManager 프로젝트 참조를 추가하고 필요한 패키지를 설치합니다.

PM> Install-Package Microsoft.Owin.Testing -ProjectName:ContactManager.Tests
PM> Install-Package Microsoft.AspNet.WebApi.Client -ProjectName:ContactManager.Tests
PM> Install-Package FluentAssertions -ProjectName:ContactManager.Tests
패키지 설명
Microsoft.Owin.Testing OWIN 기반 ASP.NET 응용프로그램의 인메모리 통합 테스트 도구입니다.
Microsoft.AspNet.WebApi.Client REST API 요청 작성에 도움을 줍니다.
FluentAssertions 서술형 검증 코드 작성을 도와줍니다. 개인적으로 즐겨 사용하는 도구이며 사용하지 않아도 큰 상관 없습니다.

테스트 코드를 작성합니다.

using System.Net;
using System.Net.Http;
using System.Net.Http.Formatting;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Owin.Testing;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ContactManager.Tests
{
    [TestClass]
    public class ContactsApiTest
    {
        private TestServer _server;

        [TestInitialize]
        public void Initialize()
        {
            this._server = TestServer.Create<Startup>();
        }

        [TestCleanup]
        public void Cleanup()
        {
            this._server.Dispose();
        }

        [TestMethod]
        public async Task Post_Should_Respond_BadRequest_IfModelInvalid()
        {
            // Arrange
            var content = new ObjectContent<Contact>(new Contact(), new JsonMediaTypeFormatter());

            // Act
            var res = await this._server.CreateRequest("api/Contacts")
                .And(req => req.Content = content)
                .PostAsync();

            // Assert
            res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
        }
    }
}

Microsoft.Owin.Testing.TestServer 클래스의 Create<>() 제네릭 메서드를 사용해 인메모리 테스트 서버를 생성합니다. 만약 응용프로그램이 Application_Start 이벤트 등에서 별도의 초기화 작업을 해주고 있다면 Action<Owin.IAppBuilder> 대리자 매개변수를 입력받는 Create() 메서드를 사용할 수 있습니다.

this._server = TestServer.Create(app =>
{
    new Startup().Configuration(app);
    // Do something
});

Post_Should_Respond_BadRequest_IfModelInvalid() 테스트 메서드는 필수 속성이 설정되지 않은 모델 개체를 사용해 컨텐트를 만들어 POST 요청을 보낸 후 응답 상태 코드가 400(Bad Request)인지 검사합니다.

테스트 실패

test failed

테스트를 실행하면 실패합니다. 실패 메시지에는 BadRequest가 기대되었지만 실제 값은 OK라는 내용이 들어있습니다.

구현

모델 상태를 검증하는 코드를 Post() 메서드에 추가합니다.

[Route, ResponseType(typeof(Contact))]
public IHttpActionResult Post(Contact contact)
{
    if (ModelState.IsValid == false)
    {
        return BadRequest(ModelState);
    }
    Add(contact);
    return Ok(contact);
}

테스트 성공

기능 구현이 완료되었으니 실패한 테스트를 다시 실행하면 성공하는 것을 확인할 수 있습니다.

{
   "Message":"The request is invalid.",
   "ModelState":{
      "contact.FirstName":[
         "The FirstName field is required."
      ],
      "contact.LastName":[
         "The LastName field is required."
      ],
      "contact.Email":[
         "The Email field is required."
      ]
   }
}

Microsoft.Owin.Testing.TestServer를 사용한 테스트 시 유용한 몇 가지 팁입니다.

System.Net.Http.HttpClient

HttpClient를 사용해 테스트 서버로 요청을 보내고 싶다면 아래처럼 인스턴스를 만들 수 있습니다.

var server = TestServer.Create...
var client = new HttpClient(server.Handler);

이렇게 만들어진 HttpClient를 통한 모든 요청은 TestServer로 전달됩니다.

또한 TestServer 클래스는 HttpClient 개체를 반환하는 HttpClient 속성을 제공합니다. 이 속성을 사용할 때 주의해야할 것이 있는데 매 호출마다 새로운 HttpClient 인스턴스를 만들어 반환한다는 점입니다. HttpClient 속성을 사용하면 위의 테스트 메서드는 이렇게 변경될 수 있습니다.

[TestMethod]
public async Task Post_Should_Respond_BadRequest_IfModelInvalid()
{
    // Arrange

    // Act
    var res = await this._server.HttpClient.PostAsJsonAsync("api/Contacts", new Contact());

    // Assert
    res.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

App_Data

개발 환경에서 App_Data 폴더의 데이터베이스 파일을 사용할 때 통합 테스트가 정상적으로 동작하려면 web.config의 연결 문자열 설정을 테스트 프로젝트의 app.config 파일에 복사한 뒤 DataDirectory를 설정해 줘야 합니다. 솔루션 디렉토리 구조에 따라 차이가 있겠지만 통합 테스트 어셈블리 출력 경로를 기준으로 다음과 같이 계산된 App_Data 폴더 경로를 AppDomain.SetData() 메서드를 호출해 설정합니다. 이 작업은 통합 테스트 루트 클래스를 만들고 이 클래스의 정적 생성자에서 실행하는 것을 추천합니다.

var currentDir = new DirectoryInfo(Directory.GetCurrentDirectory());
var solutionDir = currentDir.Parent.Parent.Parent;
var dataDir = Path.Combine(solutionDir.FullName, "ProjectName", "App_Data");
AppDomain.CurrentDomain.SetData("DataDirectory", dataDir);

결론

소프트웨어 테스트에 있어서 자동화는 아무리 강조해도 지나치지 않습니다. OWIN 기반 ASP.NET Web API 서비스는 Microsoft.Owin.Testing 패키지를 통해 쉽고 효율적으로 통합 테스트될 수 있습니다.

Advertisements

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

Google+ photo

Google+의 계정을 사용하여 댓글을 남깁니다. 로그아웃 / 변경 )

%s에 연결하는 중