사용자 고유의 테스트 더블로 테스트
참고 항목
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);
}
}
}
.NET