사용자 고유의 테스트 더블로 테스트

참고 항목

EF6 이상만 - 이 페이지에서 다루는 기능, API 등은 Entity Framework 6에 도입되었습니다. 이전 버전을 사용하는 경우 이 정보의 일부 또는 전체가 적용되지 않습니다.

애플리케이션에 대한 테스트를 작성할 때 데이터베이스에 충돌하지 않도록 하는 것이 바람직한 경우가 많습니다. Entity Framework를 사용하면 메모리 내 데이터를 사용하는 테스트에 의해 정의된 동작을 포함하는 컨텍스트를 만들어 이를 달성할 수 있습니다.

테스트 더블 만들기 옵션

컨텍스트의 메모리 내 버전을 만드는 데 사용할 수 있는 두 가지 방법이 있습니다.

  • 사용자 고유의 테스트 더블 만들기 – 이 접근 방식은 컨텍스트 및 DbSets의 사용자 고유의 메모리 내 구현을 작성하는 것입니다. 그러면 클래스가 작동하는 방식을 많이 제어할 수 있지만 적절한 양의 코드를 작성하고 소유하는 작업이 포함될 수 있습니다.
  • 모의 프레임워크를 사용하여 테스트 더블 만들기 – 모의 프레임워크(예: Moq)를 사용하면 런타임에 동적으로 만든 컨텍스트 및 집합의 메모리 내 구현을 사용할 수 있습니다.

이 문서에서는 사용자 고유의 테스트 더블 만들기를 다룹니다. 모의 프레임워크 사용에 대한 자세한 내용은 모의 프레임워크로 테스트를 참조하세요.

EF6 이전 버전으로 테스트

이 문서에 표시된 코드는 EF6과 호환됩니다. EF5 및 이전 버전으로 테스트하려면 가짜 컨텍스트로 테스트를 참조하세요.

EF 메모리 내 테스트 더블의 제한 사항

메모리 내 테스트 더블은 EF를 사용하는 애플리케이션의 비트에 대한 단위 테스트 수준 검사를 제공하는 좋은 방법이 될 수 있습니다. 그러나 이 작업을 수행할 때는 LINQ to Objects를 사용하여 메모리 내 데이터에 대해 쿼리를 실행합니다. 이로 인해 EF의 LINQ 공급자(LINQ to Entities)를 사용하여 데이터베이스에 대해 실행되는 SQL로 쿼리를 변환하는 것과 다른 동작이 발생할 수 있습니다.

이러한 차이점의 한 가지 예는 관련 데이터를 로드하는 것입니다. 각각 관련 게시물이 있는 일련의 블로그를 만드는 경우 메모리 내 데이터를 사용할 때 관련 게시물은 항상 각 블로그에 대해 로드됩니다. 그러나 데이터베이스에 대해 실행할 때 Include 메서드를 사용하는 경우에만 데이터가 로드됩니다.

이러한 이유로 애플리케이션이 데이터베이스에 대해 올바르게 작동하는지 확인하기 위해 항상 특정 수준의 엔드투엔드 테스트(단위 테스트 외에)를 포함하는 것이 좋습니다.

이 문서와 함께 팔로우

이 문서에서는 Visual Studio에 복사하여 원하는 경우 따를 수 있는 전체 코드 목록을 제공합니다. 단위 테스트 프로젝트를 만드는 것이 가장 쉬울 뿐 아니라 .NET Framework 4.5를 대상으로 하여 비동기를 사용하는 섹션을 완료해야 합니다.

컨텍스트 인터페이스 만들기

EF 모델을 사용하는 서비스를 테스트하는 방법을 살펴보겠습니다. EF 컨텍스트를 테스트를 위한 메모리 내 버전으로 대체할 수 있도록 EF 컨텍스트(및 메모리 내 더블)가 구현할 인터페이스를 정의합니다.

테스트하려는 서비스는 컨텍스트의 DbSet 속성을 사용하여 데이터를 쿼리 및 수정하고 SaveChanges를 호출하여 변경 내용을 데이터베이스에 푸시합니다. 따라서 이러한 멤버를 인터페이스에 포함합니다.

using System.Data.Entity;

namespace TestingDemo
{
    public interface IBloggingContext
    {
        DbSet<Blog> Blogs { get; }
        DbSet<Post> Posts { get; }
        int SaveChanges();
    }
}

EF 모델

테스트할 서비스는 BloggingContext와 및 블로그 및 게시물 클래스로 구성된 EF 모델을 사용합니다. 이 코드는 EF 디자이너에서 생성되었거나 Code First 모델일 수 있습니다.

using System.Collections.Generic;
using System.Data.Entity;

namespace TestingDemo
{
    public class BloggingContext : DbContext, IBloggingContext
    {
        public DbSet<Blog> Blogs { get; set; }
        public DbSet<Post> Posts { get; set; }
    }

    public class Blog
    {
        public int BlogId { get; set; }
        public string Name { get; set; }
        public string Url { get; set; }

        public virtual List<Post> Posts { get; set; }
    }

    public class Post
    {
        public int PostId { get; set; }
        public string Title { get; set; }
        public string Content { get; set; }

        public int BlogId { get; set; }
        public virtual Blog Blog { get; set; }
    }
}

EF 디자이너를 사용하여 컨텍스트 인터페이스 구현

해당 컨텍스트는 IBloggingContext 인터페이스를 구현합니다.

Code First를 사용하는 경우 컨텍스트를 직접 편집하여 인터페이스를 구현할 수 있습니다. EF 디자이너를 사용하는 경우 컨텍스트를 생성하는 T4 템플릿을 편집해야 합니다. edmx 파일 아래에 중첩된 <model_name>.Context.tt 파일을 열고 다음과 같은 코드 조각을 찾아 표시된 대로 인터페이스에 추가합니다.

<#=Accessibility.ForType(container)#> partial class <#=code.Escape(container)#> : DbContext, IBloggingContext

테스트할 서비스

메모리 내 테스트 더블로 테스트를 시연하기 위해 BlogService에 대한 몇 가지 테스트를 작성할 예정입니다. 이 서비스는 새 블로그(AddBlog)를 만들고 이름별로 정렬된 모든 블로그(GetAllBlogs)를 반환할 수 있습니다. GetAllBlogs 외에도 이름별로 정렬된 모든 블로그를 비동기적으로 가져오는 메서드도 제공되었습니다(GetAllBlogsAsync).

using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    public class BlogService
    {
        private IBloggingContext _context;

        public BlogService(IBloggingContext context)
        {
            _context = context;
        }

        public Blog AddBlog(string name, string url)
        {
            var blog = new Blog { Name = name, Url = url };
            _context.Blogs.Add(blog);
            _context.SaveChanges();

            return blog;
        }

        public List<Blog> GetAllBlogs()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return query.ToList();
        }

        public async Task<List<Blog>> GetAllBlogsAsync()
        {
            var query = from b in _context.Blogs
                        orderby b.Name
                        select b;

            return await query.ToListAsync();
        }
    }
}

메모리 내 테스트 더블 만들기

이제 실제 EF 모델과 이를 사용할 수 있는 서비스가 있으므로 테스트에 사용할 수 있는 메모리 내 테스트 더블을 만들어야 합니다. 해당 컨텍스트에 대한 TestContext 테스트 더블을 만들었습니다. 테스트 더블에서는 실행하려는 테스트를 지원하기 위해 원하는 동작을 선택할 수 있습니다. 이 예제에서는 SaveChanges가 호출되는 횟수를 캡처하지만 테스트 중인 시나리오를 확인하는 데 필요한 논리를 포함할 수 있습니다.

또한 DbSet의 메모리 내 구현을 제공하는 TestDbSet을 만들었습니다. DbSet의 모든 메서드(찾기 제외)에 대한 완전한 구현이 제공되었지만 테스트 시나리오에서 사용할 멤버만 구현하면 됩니다.

TestDbSet은 비동기 쿼리를 처리할 수 있도록 포함된 다른 인프라 클래스를 사용합니다.

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

namespace TestingDemo
{
    public class TestContext : IBloggingContext
    {
        public TestContext()
        {
            this.Blogs = new TestDbSet<Blog>();
            this.Posts = new TestDbSet<Post>();
        }

        public DbSet<Blog> Blogs { get; set; }
        public DbSet<Post> Posts { get; set; }
        public int SaveChangesCount { get; private set; }
        public int SaveChanges()
        {
            this.SaveChangesCount++;
            return 1;
        }
    }

    public class TestDbSet<TEntity> : DbSet<TEntity>, IQueryable, IEnumerable<TEntity>, IDbAsyncEnumerable<TEntity>
        where TEntity : class
    {
        ObservableCollection<TEntity> _data;
        IQueryable _query;

        public TestDbSet()
        {
            _data = new ObservableCollection<TEntity>();
            _query = _data.AsQueryable();
        }

        public override TEntity Add(TEntity item)
        {
            _data.Add(item);
            return item;
        }

        public override TEntity Remove(TEntity item)
        {
            _data.Remove(item);
            return item;
        }

        public override TEntity Attach(TEntity item)
        {
            _data.Add(item);
            return item;
        }

        public override TEntity Create()
        {
            return Activator.CreateInstance<TEntity>();
        }

        public override TDerivedEntity Create<TDerivedEntity>()
        {
            return Activator.CreateInstance<TDerivedEntity>();
        }

        public override ObservableCollection<TEntity> Local
        {
            get { return _data; }
        }

        Type IQueryable.ElementType
        {
            get { return _query.ElementType; }
        }

        Expression IQueryable.Expression
        {
            get { return _query.Expression; }
        }

        IQueryProvider IQueryable.Provider
        {
            get { return new TestDbAsyncQueryProvider<TEntity>(_query.Provider); }
        }

        System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return _data.GetEnumerator();
        }

        IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator()
        {
            return _data.GetEnumerator();
        }

        IDbAsyncEnumerator<TEntity> IDbAsyncEnumerable<TEntity>.GetAsyncEnumerator()
        {
            return new TestDbAsyncEnumerator<TEntity>(_data.GetEnumerator());
        }
    }

    internal class TestDbAsyncQueryProvider<TEntity> : IDbAsyncQueryProvider
    {
        private readonly IQueryProvider _inner;

        internal TestDbAsyncQueryProvider(IQueryProvider inner)
        {
            _inner = inner;
        }

        public IQueryable CreateQuery(Expression expression)
        {
            return new TestDbAsyncEnumerable<TEntity>(expression);
        }

        public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
        {
            return new TestDbAsyncEnumerable<TElement>(expression);
        }

        public object Execute(Expression expression)
        {
            return _inner.Execute(expression);
        }

        public TResult Execute<TResult>(Expression expression)
        {
            return _inner.Execute<TResult>(expression);
        }

        public Task<object> ExecuteAsync(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute(expression));
        }

        public Task<TResult> ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken)
        {
            return Task.FromResult(Execute<TResult>(expression));
        }
    }

    internal class TestDbAsyncEnumerable<T> : EnumerableQuery<T>, IDbAsyncEnumerable<T>, IQueryable<T>
    {
        public TestDbAsyncEnumerable(IEnumerable<T> enumerable)
            : base(enumerable)
        { }

        public TestDbAsyncEnumerable(Expression expression)
            : base(expression)
        { }

        public IDbAsyncEnumerator<T> GetAsyncEnumerator()
        {
            return new TestDbAsyncEnumerator<T>(this.AsEnumerable().GetEnumerator());
        }

        IDbAsyncEnumerator IDbAsyncEnumerable.GetAsyncEnumerator()
        {
            return GetAsyncEnumerator();
        }

        IQueryProvider IQueryable.Provider
        {
            get { return new TestDbAsyncQueryProvider<T>(this); }
        }
    }

    internal class TestDbAsyncEnumerator<T> : IDbAsyncEnumerator<T>
    {
        private readonly IEnumerator<T> _inner;

        public TestDbAsyncEnumerator(IEnumerator<T> inner)
        {
            _inner = inner;
        }

        public void Dispose()
        {
            _inner.Dispose();
        }

        public Task<bool> MoveNextAsync(CancellationToken cancellationToken)
        {
            return Task.FromResult(_inner.MoveNext());
        }

        public T Current
        {
            get { return _inner.Current; }
        }

        object IDbAsyncEnumerator.Current
        {
            get { return Current; }
        }
    }
}

찾기 구현

Find 메서드는 제네릭 방식으로 구현하기가 어렵습니다. Find 메서드를 사용하는 코드를 테스트해야 하는 경우 찾기를 지원해야 하는 각 엔터티 형식에 대해 테스트 DbSet을 만드는 것이 가장 쉽습니다. 그런 다음 아래와 같이 논리를 작성하여 특정 유형의 엔터티를 찾을 수 있습니다.

using System.Linq;

namespace TestingDemo
{
    class TestBlogDbSet : TestDbSet<Blog>
    {
        public override Blog Find(params object[] keyValues)
        {
            var id = (int)keyValues.Single();
            return this.SingleOrDefault(b => b.BlogId == id);
        }
    }
}

일부 테스트 작성

이는 테스트를 시작하기 위해 수행해야 하는 작업의 전부입니다. 다음 테스트는 TestContext를 만든 다음 이 컨텍스트를 기반으로 하는 서비스를 만듭니다. 그런 다음 서비스는 AddBlog 메서드를 사용하여 새 블로그를 만드는 데 사용됩니다. 마지막으로, 이 테스트는 서비스가 컨텍스트의 블로그 속성에 새 블로그를 추가하고 컨텍스트에서 SaveChanges를 호출했음을 확인합니다.

이는 메모리 내 테스트 더블을 사용하여 테스트할 수 있는 유형의 예일 뿐이며 테스트 더블의 논리를 조정하고 요구 사항을 충족하도록 확인할 수 있습니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Linq;

namespace TestingDemo
{
    [TestClass]
    public class NonQueryTests
    {
        [TestMethod]
        public void CreateBlog_saves_a_blog_via_context()
        {
            var context = new TestContext();

            var service = new BlogService(context);
            service.AddBlog("ADO.NET Blog", "http://blogs.msdn.com/adonet");

            Assert.AreEqual(1, context.Blogs.Count());
            Assert.AreEqual("ADO.NET Blog", context.Blogs.Single().Name);
            Assert.AreEqual("http://blogs.msdn.com/adonet", context.Blogs.Single().Url);
            Assert.AreEqual(1, context.SaveChangesCount);
        }
    }
}

다음은 테스트의 또 다른 예입니다. 이번에는 쿼리를 수행합니다. 테스트는 블로그 속성에 일부 데이터가 포함된 테스트 컨텍스트를 만들어 시작하며, 해당 데이터는 알파벳순이 아닙니다. 그런 다음 테스트 컨텍스트에 따라 BlogService를 만들고 GetAllBlogs에서 다시 가져오는 데이터가 이름별로 정렬되도록 할 수 있습니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace TestingDemo
{
    [TestClass]
    public class QueryTests
    {
        [TestMethod]
        public void GetAllBlogs_orders_by_name()
        {
            var context = new TestContext();
            context.Blogs.Add(new Blog { Name = "BBB" });
            context.Blogs.Add(new Blog { Name = "ZZZ" });
            context.Blogs.Add(new Blog { Name = "AAA" });

            var service = new BlogService(context);
            var blogs = service.GetAllBlogs();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}

마지막으로, TestDbSet에 포함된 비동기 인프라가 작동하는지 확인하기 위해 비동기 메서드를 사용하는 테스트를 하나 더 작성합니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    [TestClass]
    public class AsyncQueryTests
    {
        [TestMethod]
        public async Task GetAllBlogsAsync_orders_by_name()
        {
            var context = new TestContext();
            context.Blogs.Add(new Blog { Name = "BBB" });
            context.Blogs.Add(new Blog { Name = "ZZZ" });
            context.Blogs.Add(new Blog { Name = "AAA" });

            var service = new BlogService(context);
            var blogs = await service.GetAllBlogsAsync();

            Assert.AreEqual(3, blogs.Count);
            Assert.AreEqual("AAA", blogs[0].Name);
            Assert.AreEqual("BBB", blogs[1].Name);
            Assert.AreEqual("ZZZ", blogs[2].Name);
        }
    }
}