다음을 통해 공유


모의 프레임워크를 사용하여 테스트

참고 항목

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

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

테스트 더블 만들기 옵션

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

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

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

모의 프레임워크에서 EF를 사용하는 방법을 보여주려면 Moq를 사용합니다. Moq를 가져오는 가장 쉬운 방법은 NuGet에서 Moq 패키지를 설치하는 것입니다.

EF6 이전 버전으로 테스트

이 문서에 표시된 시나리오는 EF6의 DbSet에 대한 일부 변경 내용에 따라 달라집니다. EF5 및 이전 버전으로 테스트하려면 가짜 컨텍스트로 테스트를 참조하세요.

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

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

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

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

이 문서와 함께 팔로우

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

EF 모델

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

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

namespace TestingDemo
{
    public class BloggingContext : DbContext
    {
        public virtual DbSet<Blog> Blogs { get; set; }
        public virtual 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 디자이너를 사용한 가상 DbSet 속성

컨텍스트의 DbSet 속성은 가상으로 표시됩니다. 그러면 모의 프레임워크가 컨텍스트에서 파생되고 이러한 속성을 모의 구현으로 재정의할 수 있습니다.

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

public string DbSet(EntitySet entitySet)
{
    return string.Format(
        CultureInfo.InvariantCulture,
        "{0} virtual DbSet\<{1}> {2} {{ get; set; }}",
        Accessibility.ForReadOnlyProperty(entitySet),
        _typeMapper.GetTypeName(entitySet.ElementType),
        _code.Escape(entitySet));
}

테스트할 서비스

메모리 내 테스트 더블로 테스트를 시연하기 위해 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 BloggingContext _context;

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

        public Blog AddBlog(string name, string url)
        {
            var blog = _context.Blogs.Add(new Blog { Name = name, Url = url });
            _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();
        }
    }
}

비쿼리 시나리오 테스트

이는 비쿼리 메서드 테스트를 시작하기 위해 수행해야 하는 작업입니다. 다음 테스트는 Moq를 사용하여 컨텍스트를 만듭니다. 그런 다음 DbSet<블로그>를 만들고 컨텍스트의 Blogs 속성에서 반환되도록 연결합니다. 다음으로, 컨텍스트는 AddBlog 메서드를 사용하여 새 블로그를 만드는 데 사용되는 새 BlogService를 만드는 데 사용됩니다. 마지막으로, 테스트는 서비스가 새 블로그를 추가하고 컨텍스트에서 SaveChanges를 호출했음을 확인합니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Data.Entity;

namespace TestingDemo
{
    [TestClass]
    public class NonQueryTests
    {
        [TestMethod]
        public void CreateBlog_saves_a_blog_via_context()
        {
            var mockSet = new Mock<DbSet<Blog>>();

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(m => m.Blogs).Returns(mockSet.Object);

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

            mockSet.Verify(m => m.Add(It.IsAny<Blog>()), Times.Once());
            mockContext.Verify(m => m.SaveChanges(), Times.Once());
        }
    }
}

쿼리 시나리오 테스트

DbSet 테스트 더블에 대해 쿼리를 실행하려면 IQueryable 구현을 설정해야 합니다. 첫 번째 단계는 일부 메모리 내 데이터를 만드는 것입니다. 우리는 목록<블로그>를 사용합니다. 다음으로 컨텍스트 및 DBSet<블로그>를 만든 다음 DbSet에 대한 IQueryable 구현을 연결합니다. 이 구현은 목록<T>에서 작동하는 LINQ to Objects 공급자에게 위임하기만 하면 됩니다.

그런 다음 테스트 더블을 기반으로 BlogService를 만들고 GetAllBlogs에서 다시 가져오는 데이터가 이름별로 정렬되도록 할 수 있습니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Linq;

namespace TestingDemo
{
    [TestClass]
    public class QueryTests
    {
        [TestMethod]
        public void GetAllBlogs_orders_by_name()
        {
            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Provider).Returns(data.Provider);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            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);
        }
    }
}

비동기 쿼리를 사용하여 테스트

Entity Framework 6에는 쿼리를 비동기적으로 실행하는 데 사용할 수 있는 확장 메서드 집합이 도입되었습니다. 이러한 메서드의 예로는 ToListAsync, FirstAsync, ForEachAsync 등이 있습니다.

Entity Framework 쿼리는 LINQ를 사용하기 때문에 확장 메서드는 IQueryable 및 IEnumerable에 정의됩니다. 그러나 이는 Entity Framework에서만 사용하도록 설계되었기 때문에 Entity Framework 쿼리가 아닌 LINQ 쿼리에서 사용하려고 하면 다음 오류가 발생할 수 있습니다.

원본 IQueryable은 IDbAsyncEnumerable{0}을 구현하지 않습니다. IDbAsyncEnumerable을 구현하는 원본만 Entity Framework 비동기 작업에 사용할 수 있습니다. 자세한 내용은 http://go.microsoft.com/fwlink/?LinkId=287068 를 참조하세요.

비동기 메서드는 EF 쿼리에 대해 실행할 때만 지원되지만 DbSet의 메모리 내 테스트 더블에 대해 실행할 때 단위 테스트에서 사용할 수 있습니다.

비동기 메서드를 사용하려면 메모리 내 DbAsyncQueryProvider를 만들어 비동기 쿼리를 처리해야 합니다. Moq를 사용하여 쿼리 공급자를 설정할 수 있지만 코드에서 테스트 더블 구현을 만드는 것이 훨씬 쉽습니다. 이러한 구현에 대한 코드는 다음과 같습니다.

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

namespace TestingDemo
{
    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; }
        }
    }
}

이제 비동기 쿼리 공급자가 있으므로 새 GetAllBlogsAsync 메서드에 대한 단위 테스트를 작성할 수 있습니다.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using System.Collections.Generic;
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Threading.Tasks;

namespace TestingDemo
{
    [TestClass]
    public class AsyncQueryTests
    {
        [TestMethod]
        public async Task GetAllBlogsAsync_orders_by_name()
        {

            var data = new List<Blog>
            {
                new Blog { Name = "BBB" },
                new Blog { Name = "ZZZ" },
                new Blog { Name = "AAA" },
            }.AsQueryable();

            var mockSet = new Mock<DbSet<Blog>>();
            mockSet.As<IDbAsyncEnumerable<Blog>>()
                .Setup(m => m.GetAsyncEnumerator())
                .Returns(new TestDbAsyncEnumerator<Blog>(data.GetEnumerator()));

            mockSet.As<IQueryable<Blog>>()
                .Setup(m => m.Provider)
                .Returns(new TestDbAsyncQueryProvider<Blog>(data.Provider));

            mockSet.As<IQueryable<Blog>>().Setup(m => m.Expression).Returns(data.Expression);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.ElementType).Returns(data.ElementType);
            mockSet.As<IQueryable<Blog>>().Setup(m => m.GetEnumerator()).Returns(() => data.GetEnumerator());

            var mockContext = new Mock<BloggingContext>();
            mockContext.Setup(c => c.Blogs).Returns(mockSet.Object);

            var service = new BlogService(mockContext.Object);
            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);
        }
    }
}