Я пишу интеграционные тесты для приложения ASP.NET Core, используя NUnit. В своих тестах я наблюдал странное поведение: казалось, что все тесты имеют одни и те же статические свойства внутри моей тестируемой системы.
Я использую WebApplicationFactory
для загрузки своего приложения и взаимодействия с ним. Я предполагал, что я использую «свежий» экземпляр своего приложения для каждого WebApplicationFactory
, и я использовал одну фабрику для каждого теста...
Я создал минимальный воспроизводимый пример, в котором у меня просто есть статическое поле int, которое увеличивается при запуске. Конечная точка возвращает значение поля.
Мой тест повторяется несколько раз и, как ожидается, всегда будет возвращать 1. Но, как и следовало ожидать, во время второго запуска он терпит неудачу.
Program.cs
:
internal class Program
{
private static int a = 0;
private static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
a++;
app.MapGet("/test", () => a);
app.Run();
}
}
Модульный тест:
using Microsoft.AspNetCore.Mvc.Testing;
namespace Tests
{
public class Tests
{
[Test]
[Repeat(3)]
public async Task Test1()
{
var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var res = await client.GetAsync("/test");
var number = int.Parse(await res.Content.ReadAsStringAsync());
Assert.That(number, Is.EqualTo(1));
}
}
}
Я уверен, что это как-то связано с тем, как WebApplicationFactory
запускает программу, но это все равно очень неожиданно, и когда я читал документацию, об этом не упоминалось.
Может быть, это как-то связано с тем, что я использую NUnit вместо xUnit, как указано в документации? Но тогда как на это влияет поведение NUnit?
Я также пробовал использовать NUnits [NonParallelizable]
, чтобы убедиться, что тесты выполняются последовательно (это в любом случае потребуется), и в моем проекте, не являющемся примером, я использовал один класс для каждого теста.
Я заметил такое поведение, потому что регистрировал ClassMaps
с BsonClassMap
при запуске и получил ошибку о дублирующихся ключах в своих тестах.
В основном я ищу ответ о том, как запускать мои тесты таким образом, чтобы они не разделяли состояние. Мне также любопытно, почему такое поведение вообще происходит и предназначено оно или нет.
Статический код должен быть без сохранения состояния, и вы испытываете результаты, когда это не так. Кроме того, изучите атрибут [TestFixture]
в документации и посмотрите, как его можно использовать для создания интеграционных тестов.
WebApplicationFactory
для минимального хостинга в основном запускается Assembly.EntryPoint для каждого экземпляра экземпляра фабрики (который является вашим Main
методом), следовательно, ваш a++;
будет вызываться для каждого созданного и «запущенного» экземпляра фабрики (он включает в себя такие вызовы, как CreateClient
или Services
или Server
), а поскольку a
является статическим полем, оно будет использоваться на протяжении всей жизни приложения (в основном по определению).
Есть как минимум следующие варианты:
Перейдите от использования статики к одноэлементным сервисам. Т.е. инкапсулируйте a
в какой-нибудь сервис, зарегистрируйте его как синглтон и разрешите, где это необходимо. Возможно, это лучший подход в общем случае.
Используйте один экземпляр WebApplicationFactory
для всех тестов (чтобы настройка выполнялась только один раз). Например, используя OneTimeSetUp в сочетании с корневым приспособлением. Например, добавьте что-то вроде следующего в корневое пространство имен тестового проекта (см. документацию SetUpFixture):
[SetUpFixture]
public class GlobalFixture
{
public static WebApplicationFactory<Program> ApplicationFactory;
[OneTimeSetUp]
public void Setup() => ApplicationFactory = new WebApplicationFactory<Program>();
[OneTimeTearDown]
public void TearDown() => ApplicationFactory.Dispose();
}
И используйте GlobalFixture.ApplicationFactory
там, где это необходимо.
В некоторых случаях вы можете обойти это, переместив управление a
в статический конструктор (добавьте его в свой Program
класс).
Другой хакерский подход — изменить точку входа (Program.Main
) с помощью некоторой условной логики (возможно, путем введения другого статического поля), например if (a == 0) a++;
.
Я предполагал, что я использую «свежий» экземпляр своего приложения для каждого WebApplicationFactory.
Он использует новый экземпляр (что, я бы сказал, на самом деле подтверждается провалом теста при втором запуске), но у вас все еще есть одно и то же статическое поле, общее для всего процесса.
Может быть, это как-то связано с тем, что я использую NUnit вместо xUnit, как указано в документации? Но тогда как на это влияет поведение NUnit?
Как видите, это не NUnit как таковой.
Ну, я не могу перейти от статики к синглтону, поскольку не мой код хранит конфигурацию внутри статических полей. Как указано в вопросе, это происходит из материала MongoDB C# BsonMapper. Единственное решение, которое я видел, заключалось в том, чтобы «издеваться» над конфигурацией MongoDB и заставить мою тестовую реализацию убедиться, что она настроена только один раз. Но в своих интеграционных тестах я стараюсь не издеваться, так как хочу протестировать интеграцию моего приложения с моей БД и прочее (в основном все то, что мне не хватает в модульных тестах).
Кроме того, мне очень неудобно иметь изменяемое статическое поле, поскольку это может привести к отклонению поведения между тестами и его реальным запуском. Но мне кажется, что такого отклонения не должно быть. Я ожидал, что WebApplicationFactory запустит новый процесс вместо того, чтобы хранить все в одном пространстве.
@Ascendise «это взято из материала MongoDB C# BsonMapper» - трудно сказать, не видя фактического воспроизведения, но рекомендуемый подход со статическим ctor может помочь. «Я ожидал, что WebApplicationFactory запустит новый процесс» — почему? «Кроме того, мне очень неудобно иметь изменяемое статическое поле» - возможно, вам следует избегать изменяемых статических полей в большинстве ситуаций.
Трудно сказать, не видя реальной репродукции. Репродукция, которую я предоставил, в основном была предназначена для того, чтобы показать поведение, которое я наблюдал. Я видел ситуацию с Монго именно такой. Причина, по которой я думал, что это сделано именно так, заключалась в том, что я думал, что это пытается развернуть приложение само по себе, чтобы вы могли взаимодействовать с ним внутри теста. Или, главным образом, я не думал о каких-либо статических вещах, которые могли бы сохраниться какой-либо библиотекой, которую я использую, и вызвать проблемы, потому что я думал на уровне приложения, как вы это делаете при запуске интеграционных тестов. (продолжение)
При написании модульных тестов я, очевидно, ожидал бы, что изменения в статических полях сохранятся между тестами.
@Ascendise, не могли бы вы добавить фактический код/настройку. Что такое BsonMapper
? Как Вы этим пользуетесь? Опять же, пункты 3 и 4, возможно, должны решить вашу проблему, если первые два неосуществимы.
Ну, на данный момент я знаю, как действовать. Мое замешательство было в основном связано с тем, как WebApplicationFactory раскручивает SUT, а не с тем, как избежать статического состояния. И я в основном критиковал то, что было немного неожиданно, что состояние памяти сохранялось между тем, что должно было быть симулированными запусками приложения. Но это моя проблема :П
Вы уже используете одно веб-приложение для каждого теста.
Ваш тест неправильный. Каждый раз вы получаете новый экземпляр веб-приложения. Но вы заставили их всех смотреть на одну и ту же статическую переменную. В конце концов, это тот же ассемблерный/статический Program
класс.
Как предложено в комментариях, попробуйте это с синглтоном, и вы обнаружите, что он дает разные результаты.
Я бы посоветовал вам продолжать использовать веб-приложение для каждого теста, поскольку это обеспечивает лучшую изоляцию. Я бы просто переместил код в методы [SetUp]
и [TearDown]
, если вы хотите его немного почистить. И, возможно, также добавьте это в свой класс: [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
.
Проблема не имеет ничего общего с NUnit или xUnit. На самом деле это нормальное поведение static
, которое привязано к процессу (точнее, к домену приложения), а не к конкретному экземпляру вашего Program
-класса. Поскольку все ваши тесты выполняются одним и тем же процессом (некоторые тестовые платформы также допускают использование нескольких процессов для лучшей изоляции), они также используют одни и те же static
объекты.
Итак, реальная проблема здесь заключается в следующем: как «сбросить» эти статические объекты детерминированным способом?
Как уже упоминалось, все ваши тесты выполняются в одном и том же домене приложения, поэтому нет возможности сбросить их в конце процесса. Вам необходимо вручную сбросить объекты до их значений по умолчанию или любого другого значения. Есть много способов добиться этого, как указано в других ответах. Еще один довольно общий, но грязный способ — использовать отражение, чтобы получить доступ к переменным static
и установить для них все, что захотите.
В частности, это может быть хорошим началом для тестирования вашего устаревшего кода, который вы хотите модифицировать как можно меньше. Например.:
[TearDown] // will be executed after every test
public void TearDown()
{
var a = typeof(Program).GetField("a", BindingFlags.Static | BindingFlags.NonPublic);
a.SetValue(null, default(int));
}
Однако тест очень хрупкий. Как только вы переименуете статическую переменную или измените ее на свойство, сделаете ее internal
или что-то еще, ваши тесты потерпят неудачу. Поэтому вам следует подумать о рефакторинге вашего кода, чтобы каким-то образом внедрить ваши зависимости извне, используя ваш DI-контейнер. Вам также следует подумать о том, чтобы скрыть эти грязные строки в каком-нибудь SetupTest
-методе, который вы сможете переписать как можно скорее.
Я немного погрузился в WebApplicationFactory и увидел, что на
CreateClient
он проверяет, запущен ли TestServer, и запускает его при необходимости. Во время каждого запуска теста TestServer был нулевым и загружался для каждого теста. Так что я либо полностью схожу с рельсов, либо эта проблема кроется внутри WebApplicationFactory.