Мы уже несколько лет делаем наш продукт автоматизации маркетинга, и пилить фичи с высокой скоростью нам помогает CI, а точнее — большое количество автоматических тестов.
В продукте примерно 700 000 строк кода со всеми кастомизациями, и на это всё мы имеем около 7 000 тестов, и их количество постоянно растёт. За счет них мы не боимся совершать большие рефакторинги, затрагивающие многие части системы. Но, к сожалению, тесты не панацея. Каких-то тестов может не быть, какие-то тесты могут оказаться слишком дорогими, а какие-то ситуации не воспроизводятся в тестовой среде.
Практически каждая транзакция в нашей системе связана с работой с MS SQL с использованием LinqToSql. Да, технология старенькая, но мигрировать с неё нам довольно сложно, и по бизнесу она нас вполне устраивает. Более того, как я уже писал раньше, у нас даже есть свой форк LinqToSql, где мы чуть-чуть чиним его баги и добавляем кое-какой функциональности.
Для того, чтобы делать запросы к БД, используя LinqToSql, нужно использовать интерфейс IQueryable. В момент получения Enumerator’а или выполнения Execute у QueryProvider’а построенное дерево выражений с помощью Extension-методов к IQueryable транслируется в SQL, который и выполняется на SQL Server.
Так как наша бизнес-логика сильно завязана на сущностях в базе данных, наши тесты много работают с базой данных. Однако в 95% тестов мы не используем реальную базу, так как это очень дорого по времени, а довольствуемся InMemoryDatabase. Она является частью нашей тестовой инфраструктуры, о которой можно написать отдельную статью, и на самом деле представляет из себя просто Dictionary<Type, List> для каждого существующего типа сущности. В тестах наш UnitOfWork прозрачно работает с такой базой, давая доступ к EnumerableQueryable, который просто получить из любого IEnumerable, вызвав у него AsQueryable().
Покажу пример теста для понимания происходящего:
В тесте мы создаем modelContext — наш UnitOfWork, обёртка над DataContext со всякими плюшками, и потом пользуемся им, чтобы добраться до репозитория и пофильтровать какие-то сегменты. Разумеется, репозиторий ни о каких тестах не знает, просто ModelContext работает с InMemoryDatabase. Метод GetFiltered(filter) формирует некий IQueryable, а потом мы его материализуем.
С таким подходом есть проблема: мы никак не тестируем, что тот IQueryable, который мы получили из GetFiltered, транслируется в SQL. В итоге можем получить баг на продакшене примерно такого содержания:
Как сделать так, чтобы такие баги не попадали на продакшен? Можно писать тесты с реальной базой, и у нас такие есть. Они несильно отличаются от тех, что работают с InMemoryDatabase, тестовый класс просто имеет другого родителя. Вот пример:
В этом тесте всё происходит в реальной базе с последующим откатом Snapshot транзакции, и подобных ошибок пролезть не может. Но, разумеется, таких тестов у нас не очень много, всего около сотни. Число ни в какое сравнение не идёт с 7 000. И они стоят по времени заметно дороже, чем обычные.
Решение напрашивалось само: написать свою реализацию IQueryable и соответственно IQueryProvider, декорирующих EnumberableQueryble и System.Data.Linq.DataQuery. Такая реализация должна, при попытке получить результат запроса с помощью получение энумератора, или же с помощью вызова методов, приводящих к немедленному выполнению запроса, таких как Any, Count, Single, и т.д., сначала проверять, можно ли транслировать такой запрос в SQL, и если можно, просто выполнять его над обычными коллекциями.
Теперь я расскажу, как именно это реализовано, и начну с теста, что такая трансляция вообще работает:
Этот и ещё несколько тестов были написаны для проверки того, что трансляция в SQL действительно происходит и работает корректно. Вот ещё несколько примеров:
Как вы видите в последнем примере, при попытке проэнумерироваться по IQueryable, который не имеет трансляции в SQL, в тесте возникает exception.
Теперь перейдём непосредственно к реализации. Нас интересуют запросы, которые происходят внутри модели, то есть фактически нам интересны любые обращения к репозиториям. Репозиторий для каждой сущности обладает некоторым набором бизнес методов и даёт доступ к IQueryable через свойство Items, который является просто DataTable. Посмотрим на пример использования свойства Items.
Базовый класс для всех репозиториев:
Пример использования Items внутри репозитория
Пример использования вне репозитория:
Выходит, нужно добиться, чтобы Repository.Items возвращал наш хитрый IQueryable. Ну и написать наш хитрый IQueryable :)
Как уже было видно выше, Repository.Items фактически возвращает ITable, а сам table инициализируется при создании UnitOfWork:
Метод DatabaseContext.GetTable() абстрактный. У DatabaseContext есть 2 наследника: LinqDatabaseContext и InMemoryDatabaseContext. В LinqDatabaseContext, который используется при работе с реальной базой, всё просто: GetTable возвращает System.Data.Linq.Table. В InMemoryDatabase код написан такой:
Тут немного магии с кэшом и пока не очень понятный linqToSqlTranslateHelperContext, но уже видно, что требуемый нам IQueryable, который нам нужно подменять — это StubTableImpl, а так же используется вызов database.GetTable().
Начнём с database.GetTable(). Тут смысл в том, что StubTable создаётся, когда мы обращаемся при уже созданном UnitOfWork к каким-то репозиториям. Но в тесте может существовать множество UnitOfWork, и все они должны работать с одной базой. Database — и есть эта база, а StubTable — это просто способ получения доступа к этой базе.
Теперь посмотрим внимательнее на класс StubTableImpl:
StubTableImpl реализует IQueryable и IQueryProvider, делегируя всю реализацию StubTableQueryable innerQueryable. Сам StubTableQueryable выглядит так:
Приведу сразу код StubTableQueryProvider, потому что они очень связаны между собой (теперь даже кажется, что возможно было бы разумным, чтобы это был один класс):
Тут необходимо пояснить, как вообще работает построение деревьев выражений с использованием методов расширения на IQueryable в System.Linq.
Сами эти методы определены в статическом классе Queryable. Вот кусочек этого класса для понимания происходящего:
Я привел тут примеры реализации двух методов: Where и Count. Мой выбор пал на них, потому что они показывают разные способы взаимодействия интерфейсов IQueryable и IQueryProvider.
Посмотрим сначала на реализацию метода Where. Этот метод принимает IQueryable и условие фильтрации, и возвращает IQueryable. При этом вы можете легко заметить, что этот метод ничего не фильтрует. Всё, что он делает — это создаёт дерево выражений: вытаскивает дерево выражений из входящего IQueryable, добавляет к нему вызов метода Where, то есть себя же, с параметром условия фильтрации. После этого полученное новое дерево выражений передаётся в IQueryProvider.CreateQuery, который нужен как раз для того, чтобы оборачивать Expression в IQueryable.
Попробуем разобрать на примере. Допустим, у нас такой код:
Customers.Where(c => c.Sex == Sex.Male)
При этом Customers — Table. Тогда в метод Where будет передан IQueryable, в котором будет Expression — Table. После этого Where добавит себя же в конец этого Expression’а с условием, которое мы передали. Получится Table.Where(c => c.Sex == Sex.Male). Далее этот Expression оборачивается обратно в IQueryable и возвращается из метода. Тут не происходит никаких обращений к БД, просто вызов чистой функции.
Теперь посмотрим на метод Count. Он вычисляет количество элементов в запрашиваемой коллекции сразу при обращении к нему. Это происходит посредством вызова метода IQueryProvider.Execute. Этот метод принимает Expression, на основании которого он должен построить запрос, и возвращает результат этого запроса — количество. Построение Expression’а тут аналогично методу Where: берется исходный IQueryable, из него получается Expression и достраивается Count’ом. Таким образом IQueryProvider.Execute должен обойти этот Expression, понять, что от него требуется и сделать соответствующий запрос к бд.
Теперь, вооружившись новыми знаниями, вернёмся к StubTableQueryable и StubTableQueryProvider. Сейчас мы примерно понимаем, чего мы от них хотим: при вызове методов StubTableQueryable.GetEnumerator и StubTableQueryProvider.Execute мы должны взять наш Expression или IQueryable, попытаться транслировать его в SQL, используя какой-нибудь DataContext, а затем получить данные просто из памяти. Для этого в StubTableQueryProvider.Execute и StubTableQueryable.GetEnumerator написан похожий код, который сначала вызывает CheckConvertionToSql, а затем с помощью ConvertDataContextExpressionToInMemory преобразует исходный Expression и либо выполняет его с помощь EnumerableQueryble, либо вызывает Enumerator у EnumerableQueryble с преобразованным Expression’ом.
Начнём с того, как выполняется проверка, что запрос действительно транслируется в SQL. Метод CheckConversionToSql пытается получить текст запроса по его Expression’у, для чего использует DataContext.GetCommand. Небольшая проблема заключается в том, что GetCommand принимает IQueryable, а мы имеем Expression, но это не беда, на самом деле ему нужен только Expression :)
В итоге код, проверяющий, что запрос транслируется в SQL, выглядит так:
Класс FakeQueryable нужен просто как адаптер, вот его реализация:
Правильнее было бы всё-таки поправить Mindbox.Data.Linq так, чтобы существовала перегрузка GetCommand(Expression), но пока этого сделано не было.
Используемый выше linqToSqlTranslateHelperContext — это инстанс DataContext’а, который используется не только для вызова на нём GetCommand, но и для получения из него Table, связанных с базой данных. Изначальный запрос строится относительно этих Table. Если мы попробуем реально выполнить такой запрос, мы получим исключение о том, что соединение для этого DataContext’а отсутствует, ведь Connection не нужен для того, чтобы транслировать запросы, но нужен, чтобы их выполнять.
Однако получать данные из этого Expression’а нам всё-таки нужно. Для этого его приходится немного преобразовывать, для чего и используется ConvertDataContextExpressionToInMemory.
Обычно, чтобы что-то делать с Expression’ами, нужно наследоваться от ExpressionVisitor, где для каждого типа выражения есть метод, который можно переопределить, и писать там свою логику. Для замены LinqToSql-таблиц на таблицы InMemoryDatabase в Expression’ах мы так и поступили. Вот этот Visitor:
Смысл этого Visitor’а — заменить одну константу на другую. Что на что заменять передаётся в конструкторе. Вся логика написана в VisitConstant и довольно прямолинейна.
Посмотрим на создание экземпляра этого Visitor’а:
Тут мы проходимся по всем типам сущностей, которые зарегистрированы, и для каждого типа получаем Table из DataContext’а — это будет Key в конечном Dictionary, а так же InMemoryTable — это будет Value. В итоге, получившийся Visitor будет подменять все ContantExpression, Value которых присутствуют в ключах переданного в него словаря и соответствуют Table какой-то из наших сущностей, в InMemoryTable.
Может показаться, что с таким проходом будут проблемы с деревьями выражений, где мы используем не константное значение Table, а выражение, значением которого является Table. На этот случай написан вот такой тест:
Тут modelContext.Repositories.Get().Items является частью дерева выражений, и не будет заменен нашим Visitor'ом. Почему же тогда такой тест проходит? Каким образом запрос верно транслируется и каким образом происходит энумерация?
Трансляция запроса в такой ситуации не должна вызывать удивления, ведь LinqToSql во время трансляции запроса обходит дерево выражений, выполняя в нём выражения, которые являются фактическими константами. Все вызовы методов C# будут вызваны до настоящей трансляции, если они не используются в контексте, требующем выполнения на SQL сервере. Именно поэтому в запросе можно написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber()), но нельзя написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber(a)). Потому что в первом случае результат GetSomeTestNumber() будет вычислен на этапе трансляции и подставлен в запрос, а во втором GetSomeTestNumber принимает аргументом сущность, по которой идёт запрос, то есть зависит от сущность, а значит должен быть тоже транслирован. В тесте modelContext.Repositories.Get().Items будет выполнен на этапе трансляции, а Items любого репозитория возвращает StubTableImpl, Expression которого — это Table. Для особо любознательных даю ссылку на код, который делает то, что я описал выше.
Что же касается непосредственного выполнения запроса, то тут всё ещё проще. После замены первого и единственного Table в Expression'е исходного запроса он начинает выполняться как обычный Enumerable. И SelectMany просто выполняет свою часть выражения как делегат. В рамках этого выполнения мы попробуем транслировать вложенный запрос в SQL, что у нас, разумеется получится, заменим в нём Table на InMemoryTable и выполним точно так же.
Какие проблемы есть в этом решении? Основная проблема заключается в том, что всё ещё существуют ошибки в маппинге, которые не будут обнаружены таким образом. То, что запрос из IQueryable транслируется в SQL, ещё не значит, что фаза материализации, когда LinqToSql читает поток с данными и создаёт из них объекты, пройдёт успешно. Например, на этой фазе могут возникать ошибки, связанные с попытками записи Null-значний в свойства сущностей, которые не могут содержать Null. Нужно сказать, что мы попробовали тестировать и фазу материализации, но это пришлось откатить, так как нас не устроила производительность тестов в такой ситуации: она ухудшилась почти в 2 раза.
Тем не менее, наш код точно стал стабильнее, чему мы очень рады.
Вот и всё. С удовольствием отвечу на ваши вопросы :)
В продукте примерно 700 000 строк кода со всеми кастомизациями, и на это всё мы имеем около 7 000 тестов, и их количество постоянно растёт. За счет них мы не боимся совершать большие рефакторинги, затрагивающие многие части системы. Но, к сожалению, тесты не панацея. Каких-то тестов может не быть, какие-то тесты могут оказаться слишком дорогими, а какие-то ситуации не воспроизводятся в тестовой среде.
Практически каждая транзакция в нашей системе связана с работой с MS SQL с использованием LinqToSql. Да, технология старенькая, но мигрировать с неё нам довольно сложно, и по бизнесу она нас вполне устраивает. Более того, как я уже писал раньше, у нас даже есть свой форк LinqToSql, где мы чуть-чуть чиним его баги и добавляем кое-какой функциональности.
Для того, чтобы делать запросы к БД, используя LinqToSql, нужно использовать интерфейс IQueryable. В момент получения Enumerator’а или выполнения Execute у QueryProvider’а построенное дерево выражений с помощью Extension-методов к IQueryable транслируется в SQL, который и выполняется на SQL Server.
Так как наша бизнес-логика сильно завязана на сущностях в базе данных, наши тесты много работают с базой данных. Однако в 95% тестов мы не используем реальную базу, так как это очень дорого по времени, а довольствуемся InMemoryDatabase. Она является частью нашей тестовой инфраструктуры, о которой можно написать отдельную статью, и на самом деле представляет из себя просто Dictionary<Type, List> для каждого существующего типа сущности. В тестах наш UnitOfWork прозрачно работает с такой базой, давая доступ к EnumerableQueryable, который просто получить из любого IEnumerable, вызвав у него AsQueryable().
Покажу пример теста для понимания происходящего:
[TestMethod]
public void ФильтрПоСегментуНеВозвращаетТехУКогоНеБылоСегментов()
{
var customer = new CustomerTestDataBuilder(TestDatabase).Build();
using (var modelContext = CreateModelContext())
{
var filter = new SegmentFilter<Customer>(null, modelContext)
{
Segmentation = Controller.PeriodicalSegmentation,
Segment = FilterValueWithPresence<Segment>.Concrete(Controller.PeriodicalSegment1)
};
var result = modelContext.Repositories.Get<CustomerRepository>().GetFiltered(filter).ToList();
Assert.IsFalse(result.Contains(customer));
}
}
В тесте мы создаем modelContext — наш UnitOfWork, обёртка над DataContext со всякими плюшками, и потом пользуемся им, чтобы добраться до репозитория и пофильтровать какие-то сегменты. Разумеется, репозиторий ни о каких тестах не знает, просто ModelContext работает с InMemoryDatabase. Метод GetFiltered(filter) формирует некий IQueryable, а потом мы его материализуем.
С таким подходом есть проблема: мы никак не тестируем, что тот IQueryable, который мы получили из GetFiltered, транслируется в SQL. В итоге можем получить баг на продакшене примерно такого содержания:
[NotSupportedException: Method 'Boolean DoesCurrentUserHaveSmsPermissionOnProject(Int32)' has no supported translation to SQL.]
at System.Data.Linq.SqlClient.PostBindDotNetConverter.Visitor.VisitMethodCall(SqlMethodCall mc)
at System.Data.Linq.SqlClient.SqlVisitor.Visit(SqlNode node)
at System.Data.Linq.SqlClient.SqlVisitor.VisitExpression(SqlExpression exp)
at System.Data.Linq.SqlClient.SqlVisitor.VisitSelectCore(SqlSelect select)
at System.Data.Linq.SqlClient.PostBindDotNetConverter.Visitor.VisitSelect(SqlSelect select)
…
Как сделать так, чтобы такие баги не попадали на продакшен? Можно писать тесты с реальной базой, и у нас такие есть. Они несильно отличаются от тех, что работают с InMemoryDatabase, тестовый класс просто имеет другого родителя. Вот пример:
[TestMethod]
public void ЗакрытиеАктивнойСессииИСозданиеНовойВОднойТранзакции()
{
Controller.CurrentDateTimeUtc = new DateTime(2016, 11, 1, 0, 0, 0, DateTimeKind.Utc);
var sessionStartDateTime = Controller.CurrentDateTimeUtc.Value.AddHours(-1);
using (var modelContext = CreateModelContext())
{
var customer = new CustomerTestDataBuilder(modelContext).Build();
var activeSession = new CustomerSessionTestDataBuilder(modelContext)
.WithLastCustomer(customer)
.Active()
.WithStartDateTimeUtc(sessionStartDateTime)
.Build();
modelContext.SubmitTestData();
var newSession = new CustomerSession
{
PointOfContact = activeSession.PointOfContact,
DeviceGuid = activeSession.DeviceGuid,
IpAddress = activeSession.IpAddress,
ScreenResolution = activeSession.IpAddress,
IsAuthenticated = false
};
newSession.SetStartDateTimeUtc(modelContext, Controller.CurrentDateTimeUtc.Value, customer);
newSession.SetUserAgent(activeSession.UserAgent.UserAgentString, modelContext);
newSession.SetLastCustomer(modelContext, customer, copyWebSiteVisitActions: false);
modelContext.Repositories.Get<CustomerSessionRepository>().Add(newSession);
modelContext.SubmitChanges();
Assert.IsNull(activeSession.IsActiveOrNull);
Assert.IsNotNull(newSession.IsActiveOrNull);
}
}
В этом тесте всё происходит в реальной базе с последующим откатом Snapshot транзакции, и подобных ошибок пролезть не может. Но, разумеется, таких тестов у нас не очень много, всего около сотни. Число ни в какое сравнение не идёт с 7 000. И они стоят по времени заметно дороже, чем обычные.
Решение напрашивалось само: написать свою реализацию IQueryable и соответственно IQueryProvider, декорирующих EnumberableQueryble и System.Data.Linq.DataQuery. Такая реализация должна, при попытке получить результат запроса с помощью получение энумератора, или же с помощью вызова методов, приводящих к немедленному выполнению запроса, таких как Any, Count, Single, и т.д., сначала проверять, можно ли транслировать такой запрос в SQL, и если можно, просто выполнять его над обычными коллекциями.
Теперь я расскажу, как именно это реализовано, и начну с теста, что такая трансляция вообще работает:
[TestMethod]
public void ПолучениеВсехСущностейИзТаблицы()
{
var testEntity1 = new SomeTestEntityTestDataBuilder(TestDatabase).Build();
var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).Build();
using (var modelContext = CreateModelContext())
{
var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items.Select(e => e.Id);
var sqlQuery = query.ToString();
var expectedQuery =
$"SELECT [t0].[{nameof(SomeTestEntity.Id)}]\r\n" +
$"FROM [{SomeTestEntity.TableName}] AS [t0]";
Assert.AreEqual(expectedQuery, sqlQuery);
var entities = query.ToList();
Assert.AreEqual(2, entities.Count);
Assert.IsTrue(entities.Contains(testEntity1.Id));
Assert.IsTrue(entities.Contains(testEntity2.Id));
}
}
Этот и ещё несколько тестов были написаны для проверки того, что трансляция в SQL действительно происходит и работает корректно. Вот ещё несколько примеров:
Парочка тестов под спойлером
[TestMethod]
public void ЗапросСИспользованиемДвухСущностейСИспользованиемEntityRefиInheritanceMapping()
{
SomeAbstractTestEntity testEntity1 = new SomeTestEntityChildTestDataBuilder(TestDatabase).WithId(1).Build();
var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(2).Build();
var anotherTestEntity1 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithLinkedEntity(testEntity1).Build();
var anotherTestEntity2 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(3).Build();
using (var modelContext = CreateModelContext())
{
var query = modelContext.Repositories.Get<AnotherTestEntityRepository>()
.Items
.Where(a => a.SomeTestEntity == testEntity1)
.Select(a => a.Id);
var entities = query.ToList();
Assert.AreEqual(1, entities.Count);
Assert.IsTrue(entities.Contains(anotherTestEntity1.Id));
var sqlQuery = query.ToString();
var expectedQuery =
$"SELECT [t0].[{nameof(AnotherTestEntity.Id)}]\r\n" +
$"FROM [{AnotherTestEntity.TableName}] AS [t0]\r\n" +
$"WHERE [t0].[{nameof(AnotherTestEntity.SomeTestEntityId)}] = @p0";
Assert.AreEqual(expectedQuery, sqlQuery);
}
}
[TestMethod]
public void ЗапросНеТранслируетсяВSQL()
{
using (var modelContext = CreateModelContext())
{
var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items.Where(e => e.ToString() == "asdf");
AssertException.Throws<InvalidOperationException>(
() => query.ToList(),
"ToStringOnlySupportedForPrimitiveTypes");
}
}
Как вы видите в последнем примере, при попытке проэнумерироваться по IQueryable, который не имеет трансляции в SQL, в тесте возникает exception.
Теперь перейдём непосредственно к реализации. Нас интересуют запросы, которые происходят внутри модели, то есть фактически нам интересны любые обращения к репозиториям. Репозиторий для каждой сущности обладает некоторым набором бизнес методов и даёт доступ к IQueryable через свойство Items, который является просто DataTable. Посмотрим на пример использования свойства Items.
Базовый класс для всех репозиториев:
public abstract class Repository<TEntity> : Repository
{
private ITable<TEntity> table;
public IQueryable<TEntity> Items
{
get { return table; }
}
}
Пример использования Items внутри репозитория
public class CustomerRepository : ChangeRestrictedRepository<Customer, int, CustomerInitialState>
{
public List<Customer> GetCustomersByEmail(string email)
{
if (String.IsNullOrEmpty(email))
throw new ArgumentException("Email не указан.", nameof(email));
return Items.Where(user => user.Email == email).ToList();
}
}
Пример использования вне репозитория:
FmcgPurchase = Add(ReverseSingleLinkedItemFilter<CustomerAction, FmcgPurchase>.GetFactory(
"fmcgpurchase",
modelContext => customerAction => modelContext
.Repositories
.Get<FmcgPurchaseRepository>()
.Items
.Where(fmcgPurchase => fmcgPurchase.CustomerAction == customerAction),
canLinkedItemBeAbsent: true));
Выходит, нужно добиться, чтобы Repository.Items возвращал наш хитрый IQueryable. Ну и написать наш хитрый IQueryable :)
Как уже было видно выше, Repository.Items фактически возвращает ITable, а сам table инициализируется при создании UnitOfWork:
public override void SetRepositoryRegistry(RepositoryRegistry repositories)
{
table = repositories.DatabaseContext.GetTable<TEntity>();
}
Метод DatabaseContext.GetTable() абстрактный. У DatabaseContext есть 2 наследника: LinqDatabaseContext и InMemoryDatabaseContext. В LinqDatabaseContext, который используется при работе с реальной базой, всё просто: GetTable возвращает System.Data.Linq.Table. В InMemoryDatabase код написан такой:
protected internal override ITable<T> GetTable<T>()
{
if (!tables.ContainsKey(typeof(T)))
tables.Add(
typeof(T),
new StubTableImpl<T>(
this,
(InMemoryTable<T>)database.GetTable<T>(),
linqToSqlTranslateHelperContext));
return (ITable<T>)tables[typeof(T)];
}
Тут немного магии с кэшом и пока не очень понятный linqToSqlTranslateHelperContext, но уже видно, что требуемый нам IQueryable, который нам нужно подменять — это StubTableImpl, а так же используется вызов database.GetTable().
Начнём с database.GetTable(). Тут смысл в том, что StubTable создаётся, когда мы обращаемся при уже созданном UnitOfWork к каким-то репозиториям. Но в тесте может существовать множество UnitOfWork, и все они должны работать с одной базой. Database — и есть эта база, а StubTable — это просто способ получения доступа к этой базе.
Теперь посмотрим внимательнее на класс StubTableImpl:
public class StubTableImpl<T> : ITable<T>, IStubTable
where T : class
{
internal StubTableImpl(
InMemoryDatabaseContext databaseContext,
InMemoryTable<T> inMemoryTable,
DataContext linqToSqlTranslateHelperContext)
{
InnerTable = inMemoryTable;
innerQueryable = new StubTableQueryable<T>(
databaseContext,
linqToSqlTranslateHelperContext.GetTable<T>());
}
public Type ElementType
{
get { return innerQueryable.ElementType; }
}
public Expression Expression
{
get { return innerQueryable.Expression; }
}
public IQueryProvider Provider
{
get { return innerQueryable.Provider; }
}
Type IStubTable.EntityType
{
get { return typeof(T); }
}
public override string ToString()
{
return innerQueryable.Select(e => e).ToString();
}
IEnumerable IStubTable.Items
{
get { return InnerTable; }
}
}
StubTableImpl реализует IQueryable и IQueryProvider, делегируя всю реализацию StubTableQueryable innerQueryable. Сам StubTableQueryable выглядит так:
internal class StubTableQueryable<TEntity> : IOrderedQueryable<TEntity>
{
private readonly InMemoryDatabaseContext inMemoryContext;
private readonly IQueryable<TEntity> dataContextQueryable;
private readonly StubTableQueryProvider stubTableQueryProvider;
public StubTableQueryable(
InMemoryDatabaseContext inMemoryContext,
IQueryable<TEntity> dataContextQueryable)
{
this.inMemoryContext = inMemoryContext;
this.dataContextQueryable = dataContextQueryable;
stubTableQueryProvider = new StubTableQueryProvider(inMemoryContext, dataContextQueryable);
}
public IEnumerator<TEntity> GetEnumerator()
{
inMemoryContext.CheckConvertionToSql(Expression);
IEnumerable<TEntity> enumerable =
new EnumerableQuery<TEntity>(inMemoryContext.ConvertDataContextExpressionToInMemory(Expression));
return enumerable.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public Expression Expression { get { return dataContextQueryable.Expression; } }
public Type ElementType { get { return dataContextQueryable.ElementType; } }
public IQueryProvider Provider { get { return stubTableQueryProvider; } }
public override string ToString()
{
return inMemoryContext.GetQueryText(Expression);
}
}
Приведу сразу код StubTableQueryProvider, потому что они очень связаны между собой (теперь даже кажется, что возможно было бы разумным, чтобы это был один класс):
internal class StubTableQueryProvider : IQueryProvider
{
private static readonly IQueryProvider enumerableQueryProvider = Array.Empty<object>().AsQueryable().Provider;
private readonly InMemoryDatabaseContext inMemoryContext;
private readonly IQueryable dataContextQueryable;
public StubTableQueryProvider(
InMemoryDatabaseContext inMemoryContext,
IQueryable dataContextQueryable)
{
this.inMemoryContext = inMemoryContext;
this.dataContextQueryable = dataContextQueryable;
}
public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
{
return new StubTableQueryable<TElement>(
inMemoryContext,
dataContextQueryable.Provider.CreateQuery<TElement>(expression));
}
public object Execute(Expression expression)
{
inMemoryContext.CheckConvertionToSql(expression);
return enumerableQueryProvider.Execute(inMemoryContext.ConvertDataContextExpressionToInMemory(expression));
}
public TResult Execute<TResult>(Expression expression)
{
inMemoryContext.CheckConvertionToSql(expression);
return enumerableQueryProvider.Execute<TResult>(inMemoryContext.ConvertDataContextExpressionToInMemory(expression));
}
}
Тут необходимо пояснить, как вообще работает построение деревьев выражений с использованием методов расширения на IQueryable в System.Linq.
Сами эти методы определены в статическом классе Queryable. Вот кусочек этого класса для понимания происходящего:
public static class Queryable
{
public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate)
{
return source.Provider.CreateQuery<TSource>(
Expression.Call(
null,
GetMethodInfo(Queryable.Where, source, predicate),
new Expression[] { source.Expression, Expression.Quote(predicate) }
));
}
public static int Count<TSource>(this IQueryable<TSource> source)
{
return source.Provider.Execute<int>(
Expression.Call(
null,
GetMethodInfo(Queryable.Count, source),
new Expression[] { source.Expression }
));
}
}
Я привел тут примеры реализации двух методов: Where и Count. Мой выбор пал на них, потому что они показывают разные способы взаимодействия интерфейсов IQueryable и IQueryProvider.
Посмотрим сначала на реализацию метода Where. Этот метод принимает IQueryable и условие фильтрации, и возвращает IQueryable. При этом вы можете легко заметить, что этот метод ничего не фильтрует. Всё, что он делает — это создаёт дерево выражений: вытаскивает дерево выражений из входящего IQueryable, добавляет к нему вызов метода Where, то есть себя же, с параметром условия фильтрации. После этого полученное новое дерево выражений передаётся в IQueryProvider.CreateQuery, который нужен как раз для того, чтобы оборачивать Expression в IQueryable.
Попробуем разобрать на примере. Допустим, у нас такой код:
Customers.Where(c => c.Sex == Sex.Male)
При этом Customers — Table. Тогда в метод Where будет передан IQueryable, в котором будет Expression — Table. После этого Where добавит себя же в конец этого Expression’а с условием, которое мы передали. Получится Table.Where(c => c.Sex == Sex.Male). Далее этот Expression оборачивается обратно в IQueryable и возвращается из метода. Тут не происходит никаких обращений к БД, просто вызов чистой функции.
Теперь посмотрим на метод Count. Он вычисляет количество элементов в запрашиваемой коллекции сразу при обращении к нему. Это происходит посредством вызова метода IQueryProvider.Execute. Этот метод принимает Expression, на основании которого он должен построить запрос, и возвращает результат этого запроса — количество. Построение Expression’а тут аналогично методу Where: берется исходный IQueryable, из него получается Expression и достраивается Count’ом. Таким образом IQueryProvider.Execute должен обойти этот Expression, понять, что от него требуется и сделать соответствующий запрос к бд.
Теперь, вооружившись новыми знаниями, вернёмся к StubTableQueryable и StubTableQueryProvider. Сейчас мы примерно понимаем, чего мы от них хотим: при вызове методов StubTableQueryable.GetEnumerator и StubTableQueryProvider.Execute мы должны взять наш Expression или IQueryable, попытаться транслировать его в SQL, используя какой-нибудь DataContext, а затем получить данные просто из памяти. Для этого в StubTableQueryProvider.Execute и StubTableQueryable.GetEnumerator написан похожий код, который сначала вызывает CheckConvertionToSql, а затем с помощью ConvertDataContextExpressionToInMemory преобразует исходный Expression и либо выполняет его с помощь EnumerableQueryble, либо вызывает Enumerator у EnumerableQueryble с преобразованным Expression’ом.
Начнём с того, как выполняется проверка, что запрос действительно транслируется в SQL. Метод CheckConversionToSql пытается получить текст запроса по его Expression’у, для чего использует DataContext.GetCommand. Небольшая проблема заключается в том, что GetCommand принимает IQueryable, а мы имеем Expression, но это не беда, на самом деле ему нужен только Expression :)
В итоге код, проверяющий, что запрос транслируется в SQL, выглядит так:
public string GetQueryText(Expression expression)
{
return queryExpressionToQueryText.GetOrAdd(
expression.ToString(),
expressionText =>
{
var fakeQueryable = new FakeQueryable(expression);
var result = linqToSqlTranslateHelperContext.GetCommand(fakeQueryable);
return result.CommandText;
});
}
Класс FakeQueryable нужен просто как адаптер, вот его реализация:
public class FakeQueryable : IQueryable
{
public FakeQueryable(Expression expression)
{
Expression = expression;
}
public IEnumerator GetEnumerator()
{
throw new NotSupportedException();
}
public Expression Expression { get; }
public Type ElementType { get { throw new NotSupportedException(); } }
public IQueryProvider Provider { get { throw new NotSupportedException(); } }
}
Правильнее было бы всё-таки поправить Mindbox.Data.Linq так, чтобы существовала перегрузка GetCommand(Expression), но пока этого сделано не было.
Используемый выше linqToSqlTranslateHelperContext — это инстанс DataContext’а, который используется не только для вызова на нём GetCommand, но и для получения из него Table, связанных с базой данных. Изначальный запрос строится относительно этих Table. Если мы попробуем реально выполнить такой запрос, мы получим исключение о том, что соединение для этого DataContext’а отсутствует, ведь Connection не нужен для того, чтобы транслировать запросы, но нужен, чтобы их выполнять.
Однако получать данные из этого Expression’а нам всё-таки нужно. Для этого его приходится немного преобразовывать, для чего и используется ConvertDataContextExpressionToInMemory.
Обычно, чтобы что-то делать с Expression’ами, нужно наследоваться от ExpressionVisitor, где для каждого типа выражения есть метод, который можно переопределить, и писать там свою логику. Для замены LinqToSql-таблиц на таблицы InMemoryDatabase в Expression’ах мы так и поступили. Вот этот Visitor:
public class ConstantObjectReplaceExpressionVisitor<T> : ExpressionVisitor
where T : class
{
private readonly Dictionary<T, T> replacementDictionary;
public ConstantObjectReplaceExpressionVisitor(Dictionary<T, T> replacementDictionary)
{
this.replacementDictionary = replacementDictionary;
}
protected override Expression VisitConstant(ConstantExpression node)
{
var value = node.Value as T;
if (value == null)
return base.VisitConstant(node);
if (!replacementDictionary.ContainsKey(value))
return base.VisitConstant(node);
return Expression.Constant(replacementDictionary[value]);
}
public Expression ReplaceConstants(Expression sourceExpression)
{
return Visit(sourceExpression);
}
}
Смысл этого Visitor’а — заменить одну константу на другую. Что на что заменять передаётся в конструкторе. Вся логика написана в VisitConstant и довольно прямолинейна.
Посмотрим на создание экземпляра этого Visitor’а:
private ConstantObjectReplaceExpressionVisitor<IQueryable> CreateTableReplaceVisitor(DataContext dataContext)
{
var dataContextTableToInMemoryTableMap = new Dictionary<IQueryable, IQueryable>();
var entityTypes = ModelApplicationHostController.Instance
.ModelConfiguration
.DatabaseModel
.GetRepositoriesByEntity()
.Keys;
foreach (var entityType in entityTypes)
{
var dataContextTable = dataContextGetTableFunc(dataContext, entityType);
if (dataContextTable == null)
throw new InvalidOperationException($"Для типа {entityType} не удалось получить таблицу из DataContext'а");
var inMemoryContextTable = GetInMemoryTable(database, entityType);
if (inMemoryContextTable == null)
throw new InvalidOperationException($"Для типа {entityType} не удалось получить InMemory таблицу");
dataContextTableToInMemoryTableMap.Add(dataContextTable, inMemoryContextTable);
}
return new ConstantObjectReplaceExpressionVisitor<IQueryable>(dataContextTableToInMemoryTableMap);
}
Тут мы проходимся по всем типам сущностей, которые зарегистрированы, и для каждого типа получаем Table из DataContext’а — это будет Key в конечном Dictionary, а так же InMemoryTable — это будет Value. В итоге, получившийся Visitor будет подменять все ContantExpression, Value которых присутствуют в ключах переданного в него словаря и соответствуют Table какой-то из наших сущностей, в InMemoryTable.
Может показаться, что с таким проходом будут проблемы с деревьями выражений, где мы используем не константное значение Table, а выражение, значением которого является Table. На этот случай написан вот такой тест:
[TestMethod]
public void ЗапросСИспользованиемДвухСущностей()
{
var testEntity1 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(1).Build();
var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(2).Build();
var anotherTestEntity1 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(testEntity1.Id).Build();
var anotherTestEntity2 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(3).Build();
using (var modelContext = CreateModelContext())
{
var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items
.SelectMany(e => modelContext.Repositories.Get<AnotherTestEntityRepository>()
.Items
.Where(a => a.Id == e.Id))
.Select(a => a.Id);
var entities = query.ToList();
Assert.AreEqual(1, entities.Count);
Assert.IsTrue(entities.Contains(anotherTestEntity1.Id));
var sqlQuery = query.ToString();
var expectedQuery =
$"SELECT [t1].[{nameof(AnotherTestEntity.Id)}]\r\n" +
$"FROM [{SomeTestEntity.TableName}] AS [t0], [{AnotherTestEntity.TableName}] AS [t1]\r\n" +
$"WHERE [t1].[{nameof(AnotherTestEntity.Id)}] = [t0].[{nameof(SomeTestEntity.Id)}]";
Assert.AreEqual(expectedQuery, sqlQuery);
}
}
Тут modelContext.Repositories.Get().Items является частью дерева выражений, и не будет заменен нашим Visitor'ом. Почему же тогда такой тест проходит? Каким образом запрос верно транслируется и каким образом происходит энумерация?
Трансляция запроса в такой ситуации не должна вызывать удивления, ведь LinqToSql во время трансляции запроса обходит дерево выражений, выполняя в нём выражения, которые являются фактическими константами. Все вызовы методов C# будут вызваны до настоящей трансляции, если они не используются в контексте, требующем выполнения на SQL сервере. Именно поэтому в запросе можно написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber()), но нельзя написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber(a)). Потому что в первом случае результат GetSomeTestNumber() будет вычислен на этапе трансляции и подставлен в запрос, а во втором GetSomeTestNumber принимает аргументом сущность, по которой идёт запрос, то есть зависит от сущность, а значит должен быть тоже транслирован. В тесте modelContext.Repositories.Get().Items будет выполнен на этапе трансляции, а Items любого репозитория возвращает StubTableImpl, Expression которого — это Table. Для особо любознательных даю ссылку на код, который делает то, что я описал выше.
Что же касается непосредственного выполнения запроса, то тут всё ещё проще. После замены первого и единственного Table в Expression'е исходного запроса он начинает выполняться как обычный Enumerable. И SelectMany просто выполняет свою часть выражения как делегат. В рамках этого выполнения мы попробуем транслировать вложенный запрос в SQL, что у нас, разумеется получится, заменим в нём Table на InMemoryTable и выполним точно так же.
Какие проблемы есть в этом решении? Основная проблема заключается в том, что всё ещё существуют ошибки в маппинге, которые не будут обнаружены таким образом. То, что запрос из IQueryable транслируется в SQL, ещё не значит, что фаза материализации, когда LinqToSql читает поток с данными и создаёт из них объекты, пройдёт успешно. Например, на этой фазе могут возникать ошибки, связанные с попытками записи Null-значний в свойства сущностей, которые не могут содержать Null. Нужно сказать, что мы попробовали тестировать и фазу материализации, но это пришлось откатить, так как нас не устроила производительность тестов в такой ситуации: она ухудшилась почти в 2 раза.
Тем не менее, наш код точно стал стабильнее, чему мы очень рады.
Вот и всё. С удовольствием отвечу на ваши вопросы :)
Поделиться с друзьями
jetcar
всё конечно зависит от количества и сложности тестов, но тестировать базу без базы сомнительная идея, я в своём проекте прилепил файлик базы MSSQL и с ним тесты работают как с реальной базой, это конечно помедленнее, учитывая что каждый тест в конце всё дропает и новый тест создаёт базу с нуля с накатыванием миграций, но это всё каждый раз вовсе не обязательно делать.
реальная база избавляет от некоторых ошибок которые не поймаешь работая со своей имплементацией базы, я поначалу тоже делал так, но чтото не ловилось, к сожалению уже не помню что и я всё переделал, и главное с файликом всё работает точно также как с сервером базы