Избегайте использования общей памяти между интеграционными тестами ASP.NET Core

Я пишу интеграционные тесты для приложения 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 при запуске и получил ошибку о дублирующихся ключах в своих тестах.

В основном я ищу ответ о том, как запускать мои тесты таким образом, чтобы они не разделяли состояние. Мне также любопытно, почему такое поведение вообще происходит и предназначено оно или нет.

Я немного погрузился в WebApplicationFactory и увидел, что на CreateClient он проверяет, запущен ли TestServer, и запускает его при необходимости. Во время каждого запуска теста TestServer был нулевым и загружался для каждого теста. Так что я либо полностью схожу с рельсов, либо эта проблема кроется внутри WebApplicationFactory.

Ascendise 02.05.2024 21:56

Статический код должен быть без сохранения состояния, и вы испытываете результаты, когда это не так. Кроме того, изучите атрибут [TestFixture] в документации и посмотрите, как его можно использовать для создания интеграционных тестов.

Prolog 02.05.2024 23:51
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
2
164
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Ответ принят как подходящий

WebApplicationFactory для минимального хостинга в основном запускается Assembly.EntryPoint для каждого экземпляра экземпляра фабрики (который является вашим Main методом), следовательно, ваш a++; будет вызываться для каждого созданного и «запущенного» экземпляра фабрики (он включает в себя такие вызовы, как CreateClient или Services или Server), а поскольку a является статическим полем, оно будет использоваться на протяжении всей жизни приложения (в основном по определению).

Есть как минимум следующие варианты:

  1. Перейдите от использования статики к одноэлементным сервисам. Т.е. инкапсулируйте a в какой-нибудь сервис, зарегистрируйте его как синглтон и разрешите, где это необходимо. Возможно, это лучший подход в общем случае.

  2. Используйте один экземпляр 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 там, где это необходимо.

  3. В некоторых случаях вы можете обойти это, переместив управление a в статический конструктор (добавьте его в свой Program класс).

  4. Другой хакерский подход — изменить точку входа (Program.Main) с помощью некоторой условной логики (возможно, путем введения другого статического поля), например if (a == 0) a++;.

Я предполагал, что я использую «свежий» экземпляр своего приложения для каждого WebApplicationFactory.

Он использует новый экземпляр (что, я бы сказал, на самом деле подтверждается провалом теста при втором запуске), но у вас все еще есть одно и то же статическое поле, общее для всего процесса.

Может быть, это как-то связано с тем, что я использую NUnit вместо xUnit, как указано в документации? Но тогда как на это влияет поведение NUnit?

Как видите, это не NUnit как таковой.

Ну, я не могу перейти от статики к синглтону, поскольку не мой код хранит конфигурацию внутри статических полей. Как указано в вопросе, это происходит из материала MongoDB C# BsonMapper. Единственное решение, которое я видел, заключалось в том, чтобы «издеваться» над конфигурацией MongoDB и заставить мою тестовую реализацию убедиться, что она настроена только один раз. Но в своих интеграционных тестах я стараюсь не издеваться, так как хочу протестировать интеграцию моего приложения с моей БД и прочее (в основном все то, что мне не хватает в модульных тестах).

Ascendise 03.05.2024 01:03

Кроме того, мне очень неудобно иметь изменяемое статическое поле, поскольку это может привести к отклонению поведения между тестами и его реальным запуском. Но мне кажется, что такого отклонения не должно быть. Я ожидал, что WebApplicationFactory запустит новый процесс вместо того, чтобы хранить все в одном пространстве.

Ascendise 03.05.2024 01:05

@Ascendise «это взято из материала MongoDB C# BsonMapper» - трудно сказать, не видя фактического воспроизведения, но рекомендуемый подход со статическим ctor может помочь. «Я ожидал, что WebApplicationFactory запустит новый процесс» — почему? «Кроме того, мне очень неудобно иметь изменяемое статическое поле» - возможно, вам следует избегать изменяемых статических полей в большинстве ситуаций.

Guru Stron 03.05.2024 01:09

Трудно сказать, не видя реальной репродукции. Репродукция, которую я предоставил, в основном была предназначена для того, чтобы показать поведение, которое я наблюдал. Я видел ситуацию с Монго именно такой. Причина, по которой я думал, что это сделано именно так, заключалась в том, что я думал, что это пытается развернуть приложение само по себе, чтобы вы могли взаимодействовать с ним внутри теста. Или, главным образом, я не думал о каких-либо статических вещах, которые могли бы сохраниться какой-либо библиотекой, которую я использую, и вызвать проблемы, потому что я думал на уровне приложения, как вы это делаете при запуске интеграционных тестов. (продолжение)

Ascendise 03.05.2024 01:13

При написании модульных тестов я, очевидно, ожидал бы, что изменения в статических полях сохранятся между тестами.

Ascendise 03.05.2024 01:14

@Ascendise, не могли бы вы добавить фактический код/настройку. Что такое BsonMapper? Как Вы этим пользуетесь? Опять же, пункты 3 и 4, возможно, должны решить вашу проблему, если первые два неосуществимы.

Guru Stron 03.05.2024 01:20

Ну, на данный момент я знаю, как действовать. Мое замешательство было в основном связано с тем, как WebApplicationFactory раскручивает SUT, а не с тем, как избежать статического состояния. И я в основном критиковал то, что было немного неожиданно, что состояние памяти сохранялось между тем, что должно было быть симулированными запусками приложения. Но это моя проблема :П

Ascendise 03.05.2024 01:45

Вы уже используете одно веб-приложение для каждого теста.

Ваш тест неправильный. Каждый раз вы получаете новый экземпляр веб-приложения. Но вы заставили их всех смотреть на одну и ту же статическую переменную. В конце концов, это тот же ассемблерный/статический 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-методе, который вы сможете переписать как можно скорее.

Другие вопросы по теме