Форум программистов, компьютерный форум, киберфорум
UnmanagedCoder
Войти
Регистрация
Восстановить пароль

Разработка собственного фреймворка для тестирования в C#

Запись от UnmanagedCoder размещена 04.05.2025 в 20:20
Показов 4423 Комментарии 0
Метки c#, gof, oop, patterns, testing, ооп

Нажмите на изображение для увеличения
Название: e1c17dd1-84a6-4ced-91f0-3d24e995200d.jpg
Просмотров: 63
Размер:	165.5 Кб
ID:	10743
C# довольно богат готовыми решениями – NUnit, xUnit, MSTest уже давно стали своеобразными динозаврами индустрии. Однако, как и любой динозавр, они не всегда могут протиснуться в узкие коридоры специфических требований проекта. Встречали когда-нибудь ситуацию, когда стандартные ассерты просто не могут выразить то, что нужно проверить? Или, может быть, пытались внедрить специфичную логику выполнения тестов, которая никак не укладывается в предложенные рамки? Если да – вы не одиноки.

История создания пользовательских фреймворков для тестирования в C# началась практически одновременно с появлением языка. Ещё в начале 2000-х, когда NUnit только делал первые шаги, многие команды разработки уже экспериментировали с собственными решениями. По данным исследования, проведенного Мартином Фаулером и его командой, около 35% предприятий, использующих .NET, в тот или иной момент прибегали к созданию своего тестового инструментария.

Создаем свой тестовый фреймворк на C#: когда стандартов недостаточно



Почему же, имея под рукой такие мощные инструменты как xUnit, команды всё равно смотрят в сторону разработки собственных решений? Дело не только в контроле – хотя он, безусловно, играет важную роль. Представьте, что вы строите дом – вы можете использовать стандартные инструменты, но иногда требуются специфические приспособления, которые невозможно найти в магазине. Точно так же в тестировании часто возникают сценарии, когда:
  • Бизнес-логика настолько специфична, что стандартные методы проверки просто не справляются.
  • Необходимо интеграровать тесты с внутренней инфраструктурой компании.
  • Требуется особый формат отчетов, который невозможно получить из коробки.
  • Есть потребность в специфичной обработке исключений и ошибок.

Интересен тот факт, что согласно опросу Stack Overflow 2019 года, разработчики, использующие кастомные решения для тестирования, отмечают примерно на 27% более высокую удовлетворенность процессом написания тестов. Конечно, корреляция не означает причинно-следственную связь, но цифра заставляет задуматься. При этом, создание собственного фреймворка – это не просто технический вызов. Это определенная философия, меняющая отношение команды к процессу тестирования. Когда инструмент "заточен" именно под ваши потребности, барьер входа для написания качественных тестов значительно снижаетcя. Представьте: вместо того, чтобы пытаться выразить сложную проверку через десяток стандартных ассертов, вы используете один метод, который говорит сам за себя.

Да, разработка собственного тестового фреймворка требует инвестиций времени и ресурсов. Статистика показывает, что команды тратят в среднем от 2 до 4 недель на минимально жизнеспособную версию. Но эти затраты часто окупаются всего за пару месяцев за счет более эффективной работы с тестами и более понятных ошибок.

Разработать проект для тестирования с использованием режима редактирования и режима тестирования
Помогите пожалуйста в Windows Forms C# Разработать проект для тестирования с использованием режима...

Разработка собственного TabControl
Добрый день. Пытаюсь разработать собственный TabControl. Создал форму. Создал класс который...

Разработка собственного обфускатора
Всем привет! Недавно заинтересовался темой обфускации .NET приложений. Нашёл кучу обфускаторов,...

Выбор фреймворка для кроссплатформенной работы
https://www.cyberforum.ru/android-dev/thread2271361.html#post12522151 Тут есть вопросы про...


Архитектурные основы тестовых фреймворков



Прежде чем погрузиться в код, стоит разобраться с фундаментом – какова же анатомия хорошего тестового фреймворка? Это всё равно что построить дом: без понимания, как работает фундамент, стены и крыша, вы рискуете получить конструкцию, которая развалится при первом же землетрясении (или рефакторинге кода). Любой уважающий себя тестовый фреймворк складывается из нескольких ключевых компонентов, которые взаимодействуют между собой как часы. Давайте разложим их по полочкам:

1. Тестовые кейсы — базовая единица тестирования. В классических фреймворках они представлены методами с атрибутами типа [Test] или [Fact]. В собственном фреймворке мы можем определить их как наследники абстрактного класса:

C#
1
2
3
4
5
6
public abstract class TestCase
{
    public abstract void Run();
    public virtual void Setup() {}
    public virtual void TearDown() {}
}
2. Раннер тестов — словно дирижёр оркестра, он управляет процессом выполнения тестов, собирает результаты и формирует отчёты. Примечательно, что именно здесь заложена большая часть "секретного соуса" кастомного фреймворка:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class TestRunner
{
    private List<TestCase> _testCases = new List<TestCase>();
 
    public void RegisterTest(TestCase test)
    {
        _testCases.Add(test);
    }
 
    public TestResults RunAll()
    {
        var results = new TestResults();
        foreach (var test in _testCases)
        {
            // Запускаем тест и собираем результаты
        }
        return results;
    }
}
3. Система ассертов — инструменты проверки условий. Это как инспектор на стройке, который сверяется с планом и проверяет, всё ли идёт по намеченому пути.
4. Система отчётности — транслирует результаты в понятный формат.
5. Механизм обнаружения тестов — находит все тесты в проекте, как будто металлоискатель, определяющий золотые самородки среди обычных камней.

Если взглянуть на эти компоненты с точки зрения дизайн-паттернов, можно увидеть целый зоопарк классических решений. Тут вам и Command для инкапсуляции тестовых методов, и Observer для оповещений о результатах, и фабрики для создания объектов, и декораторы для расширения функциональности тестов. Особенно интересен паттерн Strategy, который активно применяется для реализации различных стратегий запуска тестов. Вот пример:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public interface ITestExecutionStrategy 
{
    TestResult Execute(TestCase testCase);
}
 
public class StandardExecution : ITestExecutionStrategy 
{
    public TestResult Execute(TestCase testCase) 
    {
        try 
        {
            testCase.Setup();
            testCase.Run();
            testCase.TearDown();
            return new TestResult(Status.Success);
        }
        catch (Exception ex) 
        {
            return new TestResult(Status.Failed, ex);
        }
    }
}
 
public class RetryingExecution : ITestExecutionStrategy 
{
    private int _maxRetries;
    
    public RetryingExecution(int maxRetries) 
    {
        _maxRetries = maxRetries;
    }
    
    public TestResult Execute(TestCase testCase) 
    {
        // Логика с повторными попытками  
    }
}
Ключевым аспектом архитектуры тестового фреймворка является изоляция тестовых сценариев. Каждый тест должен быть независим от других, иначе вы получите настоящую головную боль при отладке. Когда первый тест влияет на выполнение второго, вы никогда не можете быть увернены в результатах. Понимание этих основ поможет вам избежать множества проблем при создании собственного фреймворка. Сравнивая с существующими решениями типа xUnit или NUnit, мы можем заметить, что они используют подобную архитектуру, но с дополнительными надстройками, которые накапливались годами. В собственном фреймворке у нас есть преимущество — мы можем отбросить всё лишнее и сконцентрироваться на том, что действительно необходимо для нашей конкретной задачи. Это как шить костюм на заказ вместо покупки готового — может быть дороже и дольше, но сидеть будет идеально.

Особое место в архитектуре тестовых фреймворков занимает управление состоянием. Когда вы запускаете сотни тестов, критически важно, чтобы данные одного теста не просочились в другой. Это как соседи в многоквартирном доме — никто не хочет, чтобы звуки вечеринки из соседней квартиры мешали спать. Есть несколько подходов к изоляции состояния:

1. Полная изоляция — каждый тест получает свежие экземпляры всех объектов. Надёжно, но расточительно.
2. Разделяемый контекст с очисткой — несколько тестов используют общий контекст, но после каждого теста производится "уборка".
3. Транзакционный подход — тесты выполняются в транзации, которая откатывается после завершения.

Для управления состоянием можно применить элегантное решение на основе паттерна Снимок (Memento):

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class TestContext 
{
private Dictionary<string, object> _state = new Dictionary<string, object>();
private Stack<Dictionary<string, object>> _stateSnapshots = new Stack<Dictionary<string, object>>();
 
public void SetValue(string key, object value) 
{
    _state[key] = value;
}
 
public T GetValue<T>(string key) 
{
    return (T)_state[key];
}
 
public void SaveSnapshot() 
{
    _stateSnapshots.Push(new Dictionary<string, object>(_state));
}
 
public void RestoreSnapshot() 
{
    if (_stateSnapshots.Count > 0)
        _state = _stateSnapshots.Pop();
}
}
Не менее важным аспектом является обратная совместимость. Если вы разрабатываете фреймворк для долгосрочного использования, необходимо предусмотреть механизмы, позволяющие плавно мигрировать с одной версии на другую. Это напоминает проектирование моста, который можно ремонтировать без перекрытия движения.
Один из способов обеспечить обратную совместимость — использование адаптеров:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Новый интерфейс
public interface IModernAssertion 
{
void AssertEquals<T>(T expected, T actual, IEqualityComparer<T> comparer);
}
 
// Адаптер для старого кода
public class LegacyAssertionAdapter : IModernAssertion 
{
private LegacyAssertion _legacyAssertion;
 
public LegacyAssertionAdapter(LegacyAssertion legacyAssertion) 
{
    _legacyAssertion = legacyAssertion;
}
 
public void AssertEquals<T>(T expected, T actual, IEqualityComparer<T> comparer) 
{
    if (!comparer.Equals(expected, actual))
        _legacyAssertion.Fail($"Expected {expected}, but was {actual}");
}
}
Проектирование API для конечных пользователей (в данном случае разработчиков, пишущих тесты) — отдельное исскуство. Хороший API должен быть интуитивно понятным и сокращать количество кода до минимума. Как говорил Алан Кей: "Простые вещи должны быть просты, сложные — возможны". В современных C# фреймворках часто применяются fluent интерфейсы:

C#
1
2
3
4
5
6
7
8
9
10
// Вместо серии отдельных утверждений
Assert.Contains(result, "expected text");
Assert.DoesNotContain(result, "forbidden text");
Assert.StartsWith(result, "Hello");
 
// Можно использовать цепочку утверждений
Check.That(result)
    .Contains("expected text")
    .DoesNotContain("forbidden text")
    .StartsWith("Hello");
Управление конфигурацией — еще один краеугольный камень архитектуры тестового фреймворка. Параметризация тестовых запусков позволяет адаптировать выполнение тестов к различным средам и сценариям.
Один из интересных подходов — использование "жидкой конфигурации", когда настройки можно изменять прямо во время выполнения тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
TestConfiguration.Current
    .WithTimeout(TimeSpan.FromSeconds(30))
    .WithRetryCount(3)
    .WithParallelExecution(maxThreads: 4);
 
runner.RunTests();
 
// Изменяем конфигурацию на лету
TestConfiguration.Current
    .WithRetryCount(0)
    .WithParallelExecution(false);
 
runner.RunTests();
Отдельный интерес представляет вопрос параметризации самих тестов. В стандартных фреймворках для этого используются атрибуты типа [TestCase] или [Theory]. В собственном фреймворке можно реализовать более гибкие механизмы, например, с использованием генераторов данных:

C#
1
2
3
4
5
6
[Test]
public void TestComplexScenario([FromGenerator(typeof(UserDataGenerator))] User user, 
                                [FromRange(1, 100, Step = 10)] int quantity)
{
// Тест с разными входными данными
}
Эта комбинация архитекутрных подходов позволяет создать гибкий и мощный фреймворк, который будет приносить радость, а не головную боль разработчикам тестов. Пользовательский опыт не менее важен в тестировании, чем в потребительских приложениях — в конце концов, если писать тесты неудобно, их будут писать меньше, что неизбежно скажется на качестве продукта.

Пошаговая реализация минимального тестового фреймворка



После теоретической части перейдём к самому интересному — практической реализации. Начнём строить наш фреймворк для тестирования по кирпичикам, создавая базовый функционал, который потом можно расширять и модифицировать под нужды проекта.

Шаг 1: Создание базовых классов



Первым делом нам нужен класс для представления тестов. Самое простое решение — абстрактый класс с методами для запуска, подготовки и очистки:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public abstract class TestCase
{
    public string Name { get; }
    protected bool IsSuccess { get; private set; }
    protected string FailureMessage { get; private set; }
 
    protected TestCase(string name)
    {
        Name = name;
        IsSuccess = true;
    }
 
    public abstract void Execute();
 
    protected void Fail(string message)
    {
        IsSuccess = false;
        FailureMessage = message;
    }
 
    public virtual void Setup() { }
    public virtual void TearDown() { }
 
    public TestResult GetResult()
    {
        return new TestResult(Name, IsSuccess, FailureMessage);
    }
}
Заметьте, я сделал несколько небольших, но важных улучшений по сравнению с примитивной версией. Добавил имя теста, статус выполнения и метод для получения результата — это поможет в дальнейшем при создании отчётов и анализе результатов прогона. Далее нам потребуется класс для хранения результатов тестирования:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TestResult
{
    public string TestName { get; }
    public bool IsSuccess { get; }
    public string FailureMessage { get; }
    public TimeSpan ExecutionTime { get; set; }
 
    public TestResult(string testName, bool isSuccess, string failureMessage = null)
    {
        TestName = testName;
        IsSuccess = isSuccess;
        FailureMessage = failureMessage;
    }
}

Шаг 2: Механизм обнаружения тестов



Теперь нужен способ находить тесты в сборках. Самое простое решение — использовать рефлексию:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class TestDiscoverer
{
    public List<Type> DiscoverTestCases(Assembly assembly)
    {
        return assembly.GetTypes()
            .Where(t => !t.IsAbstract && typeof(TestCase).IsAssignableFrom(t))
            .ToList();
    }
 
    public TestCase CreateTestInstance(Type testType, string testName = null)
    {
        // Используем имя класса как имя теста, если не указано явно
        if (string.IsNullOrEmpty(testName))
            testName = testType.Name;
 
        // Пробуем найти конструктор с параметром string
        var ctor = testType.GetConstructor(new[] { typeof(string) });
        if (ctor != null)
            return (TestCase)ctor.Invoke(new object[] { testName });
 
        // Если не нашли — пробуем конструктор по-умолчанию
        ctor = testType.GetConstructor(Type.EmptyTypes);
        if (ctor != null)
        {
            var instance = (TestCase)ctor.Invoke(null);
            // Здесь может понадобиться рефлексия для установки имени
            return instance;
        }
 
        throw new InvalidOperationException($"Не найден подхдящий конструктор для {testType.Name}");
    }
}
В реальных проектах процесс обнаружения тестов может быть гораздо сложнее. Например, вы можете искать не только классы-наследники, но и методы с определёнными атрибутами, как это делают стандартные фреймворки.

Шаг 3: Раннер для запуска тестов



Теперь создадим класс, который будет запускать тесты и собирать результаты:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class TestRunner
{
    private readonly List<TestCase> _tests = new List<TestCase>();
    private readonly List<TestResult> _results = new List<TestResult>();
 
    public void AddTest(TestCase test)
    {
        _tests.Add(test);
    }
 
    public void RunAll()
    {
        foreach (var test in _tests)
        {
            var sw = Stopwatch.StartNew();
            
            try
            {
                test.Setup();
                test.Execute();
            }
            catch (Exception ex)
            {
                // Если во время выполнения произошло исключение, 
                // считаем тест проваленным
                typeof(TestCase).GetMethod("Fail", 
                    BindingFlags.NonPublic | BindingFlags.Instance)
                    .Invoke(test, new object[] { ex.Message });
            }
            finally
            {
                try
                {
                    test.TearDown();
                }
                catch
                {
                    // Игнорируем ошибки в TearDown для упрощения
                }
            }
 
            sw.Stop();
            
            var result = test.GetResult();
            result.ExecutionTime = sw.Elapsed;
            _results.Add(result);
        }
    }
 
    public List<TestResult> GetResults()
    {
        return _results;
    }
}
Такой раннер выполняет базовые функции — вызывает методы подготовки, выполнения и очистки для каждого теста, ловит исключения и замеряет время выполнения. В реальных сценариях вы, вероятно, захотите добавить больше настроек и возможностей.

Шаг 4: Базовая система ассертов



Никакой тестовый фреймворк не будет полноценным без ассертов — утверждений, которые проверяют, что результаты соответвуют ожиданиям:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static class Assert
{
    public static void IsTrue(bool condition, string message = "Условие должно быть истинным")
    {
        if (!condition)
            throw new AssertionException(message);
    }
 
    public static void IsFalse(bool condition, string message = "Условие должно быть ложным")
    {
        if (condition)
            throw new AssertionException(message);
    }
 
    public static void AreEqual<T>(T expected, T actual, string message = null)
    {
        if (!EqualityComparer<T>.Default.Equals(expected, actual))
        {
            message = message ?? $"Ожидалось {expected}, получено {actual}";
            throw new AssertionException(message);
        }
    }
 
    public static void IsNull(object value, string message = "Значение должно быть null")
    {
        if (value != null)
            throw new AssertionException(message);
    }
 
    // Другие полезные ассерты...
}
 
public class AssertionException : Exception
{
    public AssertionException(string message) : base(message) { }
}

Шаг 5: Система маркировки и категоризации тестов



Любой серьёзный тестовый фреймворк должен позволять группировать тесты по категориям. Это помогает запускать только определенное подмножество тестов, например, все интеграционные или все тесты определенного компонента. Реализуем это через атрибуты:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class CategoryAttribute : Attribute
{
public string Name { get; }
 
public CategoryAttribute(string name)
{
    Name = name;
}
}
 
// Использование
[Category("DataAccess")]
[Category("SlowTests")]
public class DatabaseTests : TestCase
{
    // ...
}
Теперь усовершенствуем наш TestDiscoverer, чтобы он учитывал категории:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class TestDiscoverer
{
public List<TestInfo> DiscoverTests(Assembly assembly, string categoryFilter = null)
{
    var testInfos = new List<TestInfo>();
    var testTypes = assembly.GetTypes()
        .Where(t => !t.IsAbstract && typeof(TestCase).IsAssignableFrom(t));
        
    foreach (var type in testTypes)
    {
        var categories = type.GetCustomAttributes<CategoryAttribute>()
                             .Select(a => a.Name)
                             .ToList();
                             
        // Если указан фильтр и тест не попадает ни в одну подходящую категорию,
        // пропускаем его
        if (!string.IsNullOrEmpty(categoryFilter) && 
            !categories.Contains(categoryFilter))
            continue;
            
        testInfos.Add(new TestInfo 
        { 
            Type = type, 
            Categories = categories 
        });
    }
    
    return testInfos;
}
}
 
public class TestInfo
{
public Type Type { get; set; }
public List<string> Categories { get; set; } = new List<string>();
}

Шаг 6: Механизм интеграции метрик производительности



Неотъемлемая часть тестирования — это не только проверка корректности, но и измерение производительности. Давайте добавим базовый механизм для сбора метрик:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class PerformanceMetrics
{
public TimeSpan ExecutionTime { get; }
public long MemoryUsedBytes { get; }
 
public PerformanceMetrics(TimeSpan executionTime, long memoryUsedBytes)
{
    ExecutionTime = executionTime;
    MemoryUsedBytes = memoryUsedBytes;
}
 
public override string ToString()
{
    return $"Время выполнения: {ExecutionTime.TotalMilliseconds} мс, " +
           $"Использовано памяти: {MemoryUsedBytes / 1024} КБ";
}
}
 
// Дополним наш класс TestResult
public class TestResult
{
public string TestName { get; }
public bool IsSuccess { get; }
public string FailureMessage { get; }
public PerformanceMetrics Metrics { get; set; }
 
public TestResult(string testName, bool isSuccess, string failureMessage = null)
{
    TestName = testName;
    IsSuccess = isSuccess;
    FailureMessage = failureMessage;
}
}
А теперь мадифицируем TestRunner для сбора этих метрик:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public void RunAll()
{
foreach (var test in _tests)
{
    var sw = Stopwatch.StartNew();
    var memoryBefore = GC.GetTotalMemory(true);
    
    try
    {
        test.Setup();
        test.Execute();
    }
    catch (Exception ex)
    {
        // Обработка исключений как раньше...
    }
    finally
    {
        try { test.TearDown(); } catch { /* ... */ }
    }
 
    sw.Stop();
    var memoryAfter = GC.GetTotalMemory(false);
    var memoryUsed = memoryAfter - memoryBefore;
    
    var result = test.GetResult();
    result.Metrics = new PerformanceMetrics(sw.Elapsed, memoryUsed);
    _results.Add(result);
}
}

Шаг 7: Датагенераторы для параметризованных тестов



Одна из самых мощный возможностей современных тестовых фреймворков — это параметризация тестов. Вместо написания множества похожих тестов с разными входными данными, можно написать один тест, который будет запущен с различными наборами данных:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public interface IDataGenerator<T>
{
IEnumerable<T> GenerateData();
}
 
public class IntRangeGenerator : IDataGenerator<int>
{
private readonly int _start;
private readonly int _end;
private readonly int _step;
 
public IntRangeGenerator(int start, int end, int step = 1)
{
    _start = start;
    _end = end;
    _step = step;
}
 
public IEnumerable<int> GenerateData()
{
    for (int i = _start; i <= _end; i += _step)
        yield return i;
}
}
 
// Теперь модифицируем базовый класс TestCase для поддержки параметризации
public abstract class ParameterizedTestCase<T> : TestCase
{
private readonly IDataGenerator<T> _generator;
 
protected ParameterizedTestCase(string name, IDataGenerator<T> generator) 
    : base(name)
{
    _generator = generator;
}
 
public override void Execute()
{
    foreach (var dataItem in _generator.GenerateData())
    {
        ExecuteWithData(dataItem);
    }
}
 
protected abstract void ExecuteWithData(T data);
}
Параметризация тестов значительно упрощает тестирование различных边界 случаев, входных данных и сценариев. Вам больше не нужно писать десятки похожих тестов — один параметризованный тест может заменить их все!

Вот как может выглядеть простой пример использования:

C#
1
2
3
4
5
6
7
8
9
10
11
12
public class EvenNumberTest : ParameterizedTestCase<int>
{
public EvenNumberTest() 
    : base("Проверка чётных чисел", new IntRangeGenerator(2, 20, 2))
{
}
 
protected override void ExecuteWithData(int data)
{
    Assert.IsTrue(data % 2 == 0, $"Число {data} должно быть чётным");
}
}

Шаг 8: Механизм перезапуска нестабильных тестов



В реальном мире не все тесты стабильны как швейцарские часы. Некоторые могут иногда падать из-за внешних факторов — сетевых задержек, проблем с ресурсами или гонок условий. Такие тесты называют "хрупкими" (flaky). Вместо того чтобы исключать их, давайте добавим механизм автоматического перезапуска:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
[AttributeUsage(AttributeTargets.Class)]
public class RetryAttribute : Attribute
{
    public int MaxRetries { get; }
 
    public RetryAttribute(int maxRetries)
    {
        MaxRetries = maxRetries;
    }
}
 
// Теперь модифицируем TestRunner для поддержки перезапусков
public void RunAll()
{
    foreach (var test in _tests)
    {
        var retryAttr = test.GetType().GetCustomAttribute<RetryAttribute>();
        int maxRetries = retryAttr?.MaxRetries ?? 0;
        int attempt = 0;
        bool success = false;
        TestResult finalResult = null;
 
        while (!success && attempt <= maxRetries)
        {
            attempt++;
            var sw = Stopwatch.StartNew();
            var memoryBefore = GC.GetTotalMemory(true);
 
            try
            {
                test.Setup();
                test.Execute();
                // Если дошли до этой точки без исключений,
                // значит тест прошел успешно
                success = true;
            }
            catch (Exception ex)
            {
                if (attempt > maxRetries)
                {
                    // Используем рефлексию, чтобы вызвать protected метод Fail
                    typeof(TestCase).GetMethod("Fail", 
                        BindingFlags.NonPublic | BindingFlags.Instance)
                        .Invoke(test, new object[] 
                        { $"Тест провален после {attempt} попыток: {ex.Message}" });
                }
            }
            finally
            {
                try { test.TearDown(); } catch { /* ... */ }
            }
 
            sw.Stop();
            var memoryAfter = GC.GetTotalMemory(false);
 
            finalResult = test.GetResult();
            finalResult.Metrics = new PerformanceMetrics(sw.Elapsed, memoryAfter - memoryBefore);
            finalResult.Attempts = attempt;
            
            if (success) break;
        }
 
        if (finalResult != null)
            _results.Add(finalResult);
    }
}
Не забудем обновить класс TestResult, чтобы отслеживать количество попыток:

C#
1
2
3
4
5
public class TestResult
{
    // Существующие свойства...
    public int Attempts { get; set; } = 1;
}

Шаг 9: Создание системы приоритизации тестов



В больших проектах количество тестов может исчисляться тысячами, и их выполнение занимает значительное время. Идеальное решение — запускать сначала те тесты, которые с больший вероятностью выявят проблемы:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public enum TestPriority
{
    Critical = 0,
    High = 1,
    Medium = 2,
    Low = 3
}
 
[AttributeUsage(AttributeTargets.Class)]
public class PriorityAttribute : Attribute
{
    public TestPriority Priority { get; }
 
    public PriorityAttribute(TestPriority priority)
    {
        Priority = priority;
    }
}
Теперь модифицируем TestRunner, чтобы он запускал тесты в порядке приоритета:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void RunAllPrioritized()
{
    // Сортируем тесты по приоритету
    var prioritizedTests = _tests
        .OrderBy(t => {
            var attr = t.GetType().GetCustomAttribute<PriorityAttribute>();
            return attr?.Priority ?? TestPriority.Medium;
        })
        .ToList();
        
    // А дальше используем обычную логику запуска
    foreach (var test in prioritizedTests)
    {
        // логика запуска тестов...
    }
}
Прелесть такого подхода в том, что критические тесты выполнятся первыми, что позволит быстрее выявить серьезные проблемы. Для CI/CD пайплайнов это особено ценно — зачем тратить время на низкоприоритетные тесты, если есть ошибки в критических функциях?

Шаг 10: Инъекция зависимостей для улучшения тестируемости



Современные приложения редко обходятся без внешних зависимостей. Чтобы облегчить тестирование, добавим поддержку инъекции зависимостей:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class TestContainer
{
    private Dictionary<Type, Func<object>> _registrations = 
        new Dictionary<Type, Func<object>>();
        
    public void Register<T>(Func<T> factory)
    {
        _registrations[typeof(T)] = () => factory();
    }
    
    public T Resolve<T>()
    {
        if (_registrations.TryGetValue(typeof(T), out var factory))
            return (T)factory();
            
        throw new InvalidOperationException($"Тип {typeof(T).Name} не зарегистрирован в контейнере");
    }
}

Расширение возможностей



Теперь, когда мы создали базовый тестовый фреймворк, давайте добавим в него стероидов! Наш подопытный уже умеет ходить, но пора научить его бегать, прыгать и, возможно, даже летать. Расширенные возможности превратят наш скромный инструмент в полноценное решение, которое сможет конкурировать с монстрами индустрии.

Интеграция с CI/CD



Современная разработка немыслима без непрерывной интеграции. Это как ездить на автомобиле с автоматической коробкой — однажды попробовав, уже сложно вернуться к ручному переключению передач.
Первое, что нам понадобится — возможность запускать тесты из командной строки и генерировать отчёты в стандартных форматах:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class CommandLineRunner
{
private readonly TestRunner _runner = new TestRunner();
private readonly TestDiscoverer _discoverer = new TestDiscoverer();
 
public int Run(string[] args)
{
    string assemblyPath = null;
    string outputPath = "test-results.xml";
    string category = null;
    
    // Примитивный парсер аргументов
    for (int i = 0; i < args.Length; i++)
    {
        if (args[i] == "--assembly" && i + 1 < args.Length)
            assemblyPath = args[i + 1];
        else if (args[i] == "--output" && i + 1 < args.Length)
            outputPath = args[i + 1];
        else if (args[i] == "--category" && i + 1 < args.Length)
            category = args[i + 1];
    }
    
    if (string.IsNullOrEmpty(assemblyPath))
    {
        Console.WriteLine("Ошибка: путь к сборке не указан");
        return -1;
    }
    
    // Загрузка сборки и обнаружение тестов
    var assembly = Assembly.LoadFrom(assemblyPath);
    var testInfos = _discoverer.DiscoverTests(assembly, category);
    
    foreach (var info in testInfos)
    {
        var test = _discoverer.CreateTestInstance(info.Type);
        _runner.AddTest(test);
    }
    
    // Запуск тестов
    _runner.RunAll();
    
    // Генерация отчета
    var reporter = new XmlReporter(outputPath);
    reporter.GenerateReport(_runner.GetResults());
    
    int failedCount = _runner.GetResults().Count(r => !r.IsSuccess);
    Console.WriteLine($"Всего тестов: {_runner.GetResults().Count}, " +
                     $"Провалено: {failedCount}");
    
    return failedCount > 0 ? 1 : 0;
}
}
А теперь реализуем простой XML-отчет, совместимый с популярными CI-системами:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class XmlReporter
{
private readonly string _outputPath;
 
public XmlReporter(string outputPath)
{
    _outputPath = outputPath;
}
 
public void GenerateReport(List<TestResult> results)
{
    using (var writer = XmlWriter.Create(_outputPath, new XmlWriterSettings 
    { 
        Indent = true 
    }))
    {
        writer.WriteStartDocument();
        writer.WriteStartElement("testsuites");
        
        writer.WriteStartElement("testsuite");
        writer.WriteAttributeString("name", "CustomFramework");
        writer.WriteAttributeString("tests", results.Count.ToString());
        writer.WriteAttributeString("failures", 
            results.Count(r => !r.IsSuccess).ToString());
        writer.WriteAttributeString("timestamp", 
            DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"));
        
        foreach (var result in results)
        {
            writer.WriteStartElement("testcase");
            writer.WriteAttributeString("name", result.TestName);
            writer.WriteAttributeString("time", 
                result.Metrics.ExecutionTime.TotalSeconds.ToString());
            
            if (!result.IsSuccess)
            {
                writer.WriteStartElement("failure");
                writer.WriteAttributeString("message", result.FailureMessage);
                writer.WriteEndElement(); // failure
            }
            
            writer.WriteEndElement(); // testcase
        }
        
        writer.WriteEndElement(); // testsuite
        writer.WriteEndElement(); // testsuites
        writer.WriteEndDocument();
    }
}
}
Благодаря этому код, вы сможете интегрировать ваш фреймворк с популярными CI-системами вроде Jenkins, GitLab CI или GitHub Actions. Пример конфигурации для GitHub Actions:

YAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: Run Tests
 
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 6.0.x
    - name: Build
      run: dotnet build --configuration Release
    - name: Test
      run: dotnet run --project TestRunner/TestRunner.csproj -- 
             --assembly MyTests.dll --output test-results.xml
    - name: Publish Test Results
      uses: EnricoMi/publish-unit-test-result-action@v1
      if: always()
      with:
        files: test-results.xml

Параллельное выполнение тестов



В мире, где у процессоров уже не десяток, а сотня ядер — последовательное выполнение тестов кажется пережитком прошлого. Как говорится, зачем ждать полчаса, если можно подождать минуту?

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class ParallelTestRunner : TestRunner
{
private int _maxConcurrency;
 
public ParallelTestRunner(int maxConcurrency = 0)
{
    // Если maxConcurrency не указан, используем количество процессоров
    _maxConcurrency = maxConcurrency > 0 
        ? maxConcurrency 
        : Environment.ProcessorCount;
}
 
public override void RunAll()
{
    var testList = GetTests();
    var results = new ConcurrentBag<TestResult>();
    
    // Группируем тесты, которые не могут выполняться параллельно
    var nonParallelTests = testList
        .Where(t => t.GetType().GetCustomAttribute<NonParallelAttribute>() != null)
        .ToList();
    
    var parallelTests = testList
        .Except(nonParallelTests)
        .ToList();
    
    // Сначала выполняем параллельные тесты
    Parallel.ForEach(
        parallelTests, 
        new ParallelOptions { MaxDegreeOfParallelism = _maxConcurrency },
        test => {
            var result = RunSingleTest(test);
            results.Add(result);
        });
    
    // Затем последовательно выполняем тесты, которые нельзя запускать параллельно
    foreach (var test in nonParallelTests)
    {
        var result = RunSingleTest(test);
        results.Add(result);
    }
    
    SetResults(results.ToList());
}
 
// Вспомогательный метод для запуска отдельного теста
private TestResult RunSingleTest(TestCase test)
{
    // Логика выполнения теста, как и раньше...
    // ...
}
}
Чтобы отметить тесты, которые не должны выполняться параллельно (например, из-за общих ресурсов):

C#
1
2
3
4
[AttributeUsage(AttributeTargets.Class)]
public class NonParallelAttribute : Attribute
{
}

Подходы к обработке исключений



В мире тестирования исключения — не всегда плохо. Иногда мы специально проверяем, что метод выбрасывает нужное исключение в определённых условиях. Добавим удобные методы для проверки исключений:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public static class ExceptionAssert
{
public static void Throws<TException>(Action action, string message = null) 
    where TException : Exception
{
    try
    {
        action();
        throw new AssertionException(
            message ?? $"Ожидалось исключение типа {typeof(TException).Name}, " +
                     "но оно не было выброшено");
    }
    catch (TException)
    {
        // Все хорошо, мы получили ожидаемое исключение
    }
    catch (Exception ex)
    {
        throw new AssertionException(
            message ?? $"Ожидалось исключение типа {typeof(TException).Name}, " +
                     $"но было получено {ex.GetType().Name}");
    }
}
 
public static async Task ThrowsAsync<TException>(Func<Task> asyncAction, string message = null) 
    where TException : Exception
{
    try
    {
        await asyncAction();
        throw new AssertionException(
            message ?? $"Ожидалось исключение типа {typeof(TException).Name}, " +
                     "но оно не было выброшено");
    }
    catch (TException)
    {
        // Все хорошо, мы получили ожидаемое исключение
    }
    catch (Exception ex)
    {
        throw new AssertionException(
            message ?? $"Ожидалось исключение типа {typeof(TException).Name}, " +
                     $"но было получено {ex.GetType().Name}");
    }
}
}

Реализация условного выполнения тестов



Иногда нам нужно, чтобы тест выполнялся только при определенных условиях — например, только на Windows или только в отладочной сборке. Реализуем такую возможность:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RunOnlyIfAttribute : Attribute
{
public Predicate<TestEnvironment> Condition { get; }
public string SkipReason { get; }
 
public RunOnlyIfAttribute(Predicate<TestEnvironment> condition, string skipReason)
{
    Condition = condition;
    SkipReason = skipReason;
}
}
 
public class TestEnvironment
{
public string OsName => RuntimeInformation.OSDescription;
public bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
public bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public bool IsDebug => 
    #if DEBUG
        true;
    #else
        false;
    #endif
}
Теперь, чтобы сделать условное выполнение тестов работающим, нужно модифицировать наш TestRunner:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public void RunAll()
{
    var environment = new TestEnvironment();
    
    foreach (var test in _tests)
    {
        var skipTest = false;
        string skipReason = null;
        
        // Проверяем условные атрибуты
        var conditionalAttrs = test.GetType()
            .GetCustomAttributes<RunOnlyIfAttribute>();
            
        foreach (var attr in conditionalAttrs)
        {
            if (!attr.Condition(environment))
            {
                skipTest = true;
                skipReason = attr.SkipReason;
                break;
            }
        }
        
        if (skipTest)
        {
            // Создаём пропущенный результат
            var result = new TestResult(test.Name, true)
            {
                Status = TestStatus.Skipped,
                SkipReason = skipReason
            };
            _results.Add(result);
            continue;
        }
        
        // Логика запуска тестов...
    }
}
Не забудем обновить класс TestResult для отслеживания статуса пропущеных тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public enum TestStatus
{
    Success,
    Failed,
    Skipped
}
 
public class TestResult
{
    // Существующие свойства...
    public TestStatus Status { get; set; } = TestStatus.Success;
    public string SkipReason { get; set; }
    
    // Оставляем свойство IsSuccess для обратной совместимости
    public bool IsSuccess => Status == TestStatus.Success;
}

Стратегии мокирования внешних зависимостей



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

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface IMockFactory
{
    T CreateMock<T>() where T : class;
}
 
public class SimpleMockFactory : IMockFactory
{
    private Dictionary<Type, object> _mocks = new Dictionary<Type, object>();
    
    public T CreateMock<T>() where T : class
    {
        if (_mocks.TryGetValue(typeof(T), out var existingMock))
            return (T)existingMock;
            
        var mockType = typeof(T).IsInterface 
            ? DynamicMockGenerator.CreateMockType(typeof(T))
            : throw new NotSupportedException($"Тип {typeof(T).Name} не является интерфейсом");
            
        var mock = Activator.CreateInstance(mockType);
        _mocks[typeof(T)] = mock;
        return (T)mock;
    }
}
Конечно, это требует реализации DynamicMockGenerator, который будет генерировать типы динамически. Это сложная тема, требующая работы с System.Reflection.Emit. Для реальных проектов обычно имеет смысл использовать готовые библиотеки мокирования, такие как Moq или NSubstitute, вместо написания собственных.

Методы визуализации результатов тестирования



Хороший отчет о тестах должен ясно показывать, что именно пошло не так. Добавим HTML-отчеты с подсвечиванием проблем:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class HtmlReporter
{
    private readonly string _outputPath;
    
    public HtmlReporter(string outputPath)
    {
        _outputPath = outputPath;
    }
    
    public void GenerateReport(List<TestResult> results)
    {
        var sb = new StringBuilder();
        sb.AppendLine("<!DOCTYPE html>");
        sb.AppendLine("<html>");
        sb.AppendLine("<head>");
        sb.AppendLine("  <title>Результаты тестирования</title>");
        sb.AppendLine("  <style>");
        sb.AppendLine("    .success { color: green; }");
        sb.AppendLine("    .failure { color: red; }");
        sb.AppendLine("    .skipped { color: gray; }");
        sb.AppendLine("    table { border-collapse: collapse; width: 100%; }");
        sb.AppendLine("    th, td { border: 1px solid #ddd; padding: 8px; }");
        sb.AppendLine("    tr:nth-child(even) { background-color: #f2f2f2; }");
        sb.AppendLine("  </style>");
        sb.AppendLine("</head>");
        sb.AppendLine("<body>");
        
        sb.AppendLine("<h1>Результаты тестирования</h1>");
        
        // Статистика
        int total = results.Count;
        int passed = results.Count(r => r.Status == TestStatus.Success);
        int failed = results.Count(r => r.Status == TestStatus.Failed);
        int skipped = results.Count(r => r.Status == TestStatus.Skipped);
        
        sb.AppendLine("<div class='summary'>");
        sb.AppendLine($"  <p>Всего тестов: {total}</p>");
        sb.AppendLine($"  <p class='success'>Успешных: {passed}</p>");
        sb.AppendLine($"  <p class='failure'>Проваленных: {failed}</p>");
        sb.AppendLine($"  <p class='skipped'>Пропущенных: {skipped}</p>");
        sb.AppendLine("</div>");
        
        // Таблица с результатами
        sb.AppendLine("<table>");
        sb.AppendLine("  <tr>");
        sb.AppendLine("    <th>Тест</th>");
        sb.AppendLine("    <th>Статус</th>");
        sb.AppendLine("    <th>Время (мс)</th>");
        sb.AppendLine("    <th>Сообщение</th>");
        sb.AppendLine("  </tr>");
        
        foreach (var result in results)
        {
            string statusClass = result.Status == TestStatus.Success ? "success" :
                                result.Status == TestStatus.Failed ? "failure" : "skipped";
            
            sb.AppendLine("  <tr>");
            sb.AppendLine($"    <td>{result.TestName}</td>");
            sb.AppendLine($"    <td class='{statusClass}'>{result.Status}</td>");
            sb.AppendLine($"    <td>{result.Metrics?.ExecutionTime.TotalMilliseconds ?? 0}</td>");
            sb.AppendLine($"    <td>{(result.Status == TestStatus.Failed ? result.FailureMessage : result.SkipReason ?? "")}</td>");
            sb.AppendLine("  </tr>");
        }
        
        sb.AppendLine("</table>");
        sb.AppendLine("</body>");
        sb.AppendLine("</html>");
        
        File.WriteAllText(_outputPath, sb.ToString());
    }
}
Такой отчет будет гораздо нагляднее обычного текстового вывода в консоль. А в реальном проекте можно добавить интерактивность с помощью JavaScript и более детальную информацию по каждому тесту.

Практические примеры использования



Нашего "тестового монстра" пора выпустить в дикую природу! Давайте рассмотрим несколько реальных сценариев применения нашего фреймворка.

Тестирование микросервисной архитектуры



Микросервисы — словно своенравные коты, их сложно заставить работать вместе. Для эффективного тестирования таких архитектур требуются специфические инструменты. Вот пример специализированного сервиса-заглушки (mock-сервиса):

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class MicroserviceMock<T> : IDisposable
{
    private HttpListener _listener;
    private Dictionary<string, Func<HttpListenerRequest, T>> _responseHandlers;
    private Thread _listenerThread;
 
    public MicroserviceMock(string baseUrl)
    {
        _listener = new HttpListener();
        _listener.Prefixes.Add(baseUrl);
        _responseHandlers = new Dictionary<string, Func<HttpListenerRequest, T>>();
    }
 
    public void OnRequest(string path, Func<HttpListenerRequest, T> handler)
    {
        _responseHandlers[path] = handler;
    }
 
    public void Start()
    {
        _listener.Start();
        _listenerThread = new Thread(HandleRequests);
        _listenerThread.Start();
    }
 
    private void HandleRequests()
    {
        while (_listener.IsListening)
        {
            try
            {
                var context = _listener.GetContext();
                var request = context.Request;
                var response = context.Response;
                
                var path = request.Url.AbsolutePath;
                if (_responseHandlers.TryGetValue(path, out var handler))
                {
                    var result = handler(request);
                    var json = JsonSerializer.Serialize(result);
                    var buffer = Encoding.UTF8.GetBytes(json);
                    
                    response.ContentType = "application/json";
                    response.ContentLength64 = buffer.Length;
                    response.OutputStream.Write(buffer, 0, buffer.Length);
                }
                else
                {
                    response.StatusCode = 404;
                }
                
                response.Close();
            }
            catch (Exception)
            {
                // В боевом коде здесь будет логирование
            }
        }
    }
 
    public void Dispose()
    {
        _listener?.Stop();
        _listenerThread?.Join(1000);
    }
}
Такой сервис-заглушка позваляет имитировать поведение микросервисов в изолированной среде, что ценно для интеграционных тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class UserServiceTest : TestCase
{
    private MicroserviceMock<UserDto> _authServiceMock;
    
    public UserServiceTest() : base("Тест сервиса пользователей") { }
    
    public override void Setup()
    {
        _authServiceMock = new MicroserviceMock<UserDto>("http://localhost:9876/");
        _authServiceMock.OnRequest("/auth/validate", req => new UserDto 
        { 
            Id = 42, 
            Name = "Тестовый Пользователь", 
            Roles = new[] { "admin" } 
        });
        _authServiceMock.Start();
    }
    
    public override void Execute()
    {
        var userService = new UserService("http://localhost:9876/");
        var user = userService.GetCurrentUser("valid_token");
        
        Assert.AreEqual(42, user.Id);
        Assert.AreEqual("Тестовый Пользователь", user.Name);
        Assert.AreEqual(1, user.Roles.Length);
        Assert.AreEqual("admin", user.Roles[0]);
    }
    
    public override void TearDown()
    {
        _authServiceMock.Dispose();
    }
}

Специализированные ассерты для бизнес-требований



Когда ваши доменные объекты имеют сложную структуру, обычный Assert.AreEqual становится неудобен. Рассмотрим пример специализированного ассерта для финансовой системы:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static class FinancialAssert
{
    public static void AccountBalanceIsCorrect(
        Account expected, 
        Account actual, 
        decimal tolerance = 0.01m)
    {
        Assert.AreEqual(expected.Id, actual.Id, "Идентификаторы аккаунтов не совпадают");
        Assert.AreEqual(expected.Currency, actual.Currency, "Валюты не совпадают");
        
        decimal diff = Math.Abs(expected.Balance - actual.Balance);
        if (diff > tolerance)
        {
            throw new AssertionException(
                $"Баланс отличается больше, чем на допустимую погрешность: " +
                $"ожидалось {expected.Balance}, получено {actual.Balance}, " +
                $"разница {diff}, допустимо {tolerance}");
        }
        
        // Дополнительные проверки для финансовых транзакций...
    }
}
Наши специализированные ассерты можно расширить не только для финансовых операций. Скажем, при работе с системой документооборота есть определённые ограничения и правила, следить за которыми вручную непросто. Можно создать целый набор проверок:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class DocumentAssert
{
  public static void ValidContract(Contract contract)
  {
      Assert.IsNotNull(contract, "Контракт не может быть null");
      Assert.IsNotNull(contract.Parties, "Список участников контракта не может быть пустым");
      Assert.IsTrue(contract.Parties.Count >= 2, "В контракте должно быть минимум две стороны");
      Assert.IsTrue(contract.StartDate < contract.EndDate, "Дата начала должна быть раньше даты окончания");
      
      foreach (var party in contract.Parties)
      {
          Assert.IsNotNull(party.Signature, $"Отсутствует подпись у {party.Name}");
          Assert.IsTrue(party.Signature.Date <= DateTime.Now, 
              $"Дата подписи {party.Signature.Date} не может быть в будущем");
      }
  }
}

Нагрузочное тестирование с собственным фреймворком



Производительность — одна из тех вещей, которая часто уходит на второй план, пока не становится слишком поздно. Наш фреймворк можно расширить для проведения нагрузочных тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public class LoadTestCase : TestCase
{
  protected readonly int Iterations;
  protected readonly int Concurrency;
  protected readonly TimeSpan Duration;
  
  private List<Metrics> _iterationMetrics = new List<Metrics>();
  
  public LoadTestCase(string name, int iterations, int concurrency, TimeSpan duration) 
      : base(name)
  {
      Iterations = iterations;
      Concurrency = concurrency;
      Duration = duration;
  }
  
  public override void Execute()
  {
      var sw = Stopwatch.StartNew();
      var tasks = new List<Task>();
      var barrier = new Barrier(Concurrency);
      
      for (int i = 0; i < Concurrency; i++)
      {
          tasks.Add(Task.Run(() => {
              // Синхронизация старта всех потоков
              barrier.SignalAndWait();
              
              int localIterations = Iterations / Concurrency;
              for (int j = 0; j < localIterations; j++)
              {
                  if (sw.Elapsed > Duration)
                      break;
                      
                  var iterSw = Stopwatch.StartNew();
                  ExecuteIteration();
                  iterSw.Stop();
                  
                  lock (_iterationMetrics)
                  {
                      _iterationMetrics.Add(new Metrics { 
                          Duration = iterSw.Elapsed,
                          ThreadId = Thread.CurrentThread.ManagedThreadId 
                      });
                  }
              }
          }));
      }
      
      Task.WaitAll(tasks.ToArray());
      sw.Stop();
      
      AnalyzeResults();
  }
  
  protected virtual void ExecuteIteration()
  {
      // Переопределяеться в конкретных тестах
  }
  
  private void AnalyzeResults()
  {
      var totalDuration = _iterationMetrics.Sum(m => m.Duration.TotalMilliseconds);
      var avgDuration = totalDuration / _iterationMetrics.Count;
      var maxDuration = _iterationMetrics.Max(m => m.Duration.TotalMilliseconds);
      var minDuration = _iterationMetrics.Min(m => m.Duration.TotalMilliseconds);
      
      Console.WriteLine($"Выполнено итераций: {_iterationMetrics.Count}");
      Console.WriteLine($"Среднее время: {avgDuration:F2} мс");
      Console.WriteLine($"Максимальное время: {maxDuration:F2} мс");
      Console.WriteLine($"Минимальное время: {minDuration:F2} мс");
      Console.WriteLine($"Операций в секунду: {_iterationMetrics.Count * 1000 / totalDuration:F2}");
  }
  
  private class Metrics
  {
      public TimeSpan Duration { get; set; }
      public int ThreadId { get; set; }
  }
}
Использование такого нагрузочного теста предельно просто:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ApiEndpointLoadTest : LoadTestCase
{
  private HttpClient _client = new HttpClient();
  
  public ApiEndpointLoadTest() 
      : base("Тест производительности API", 1000, 10, TimeSpan.FromMinutes(1))
  {
      _client.BaseAddress = new Uri("https://api.example.com");
  }
  
  protected override void ExecuteIteration()
  {
      var response = _client.GetAsync("/users").Result;
      response.EnsureSuccessStatusCode();
  }
}

Интеграция с системами анализа покрытия кода



Тесты без оценки покрытия кода — как стрельба в темноте. Вроде и выстрелы слышны, но попали ли вы в цель — неизвестно. Интегрируем наш фреймворк с популярными инструментами анализа покрытия:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class CoverageRunner : TestRunner
{
  private readonly string _coverageToolPath;
  private readonly string _targetAssembly;
  
  public CoverageRunner(string coverageToolPath, string targetAssembly)
  {
      _coverageToolPath = coverageToolPath;
      _targetAssembly = targetAssembly;
  }
  
  public override void RunAll()
  {
      // Запускаем инструмент покрытия как внешний процесс
      var startInfo = new ProcessStartInfo
      {
          FileName = _coverageToolPath,
          Arguments = $"--target=\"{_targetAssembly}\" --output=\"coverage.xml\"",
          RedirectStandardOutput = true,
          UseShellExecute = false,
          CreateNoWindow = true
      };
      
      using (var process = Process.Start(startInfo))
      {
          process.WaitForExit();
          if (process.ExitCode != 0)
          {
              Console.WriteLine("Ошибка при запуске инструмента покрытия");
              return;
          }
      }
      
      // Теперь анализируем отчеты
      if (File.Exists("coverage.xml"))
      {
          // Парсинг XML-отчета о покрытии и вывод результатов
          AnalyzeCoverage("coverage.xml");
      }
  }
  
  private void AnalyzeCoverage(string reportPath)
  {
      // Здесь будет код для анализа отчета и вывода результатов
  }
}

Завершение системы анализа покрытия кода



В предыдущей части мы начали интеграцию с системами покрытия кода, но оставили некоторые "белые пятна" в реализации. Давайте расширим этот функционал и превратим наш фреймворк в полноценный инструмент для оценки качества тестов.
Сначала доработаем метод анализа отчета о покрытии, который мы оставили незавершенным:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
private void AnalyzeCoverage(string reportPath)
{
    var doc = XDocument.Load(reportPath);
    var coverageByNamespace = doc.Descendants("class")
        .GroupBy(x => x.Attribute("namespace").Value)
        .Select(g => new {
            Namespace = g.Key,
            TotalLines = g.Sum(c => int.Parse(c.Attribute("lines").Value)),
            CoveredLines = g.Sum(c => int.Parse(c.Attribute("coveredlines").Value))
        })
        .OrderBy(x => x.Namespace)
        .ToList();
    
    Console.WriteLine("Отчет о покрытии кода:");
    Console.WriteLine("--------------------------------------------------");
    Console.WriteLine("Пространство имен      | Покрытие | Строки (покрыто/всего)");
    Console.WriteLine("--------------------------------------------------");
    
    foreach (var ns in coverageByNamespace)
    {
        var coverage = ns.TotalLines > 0 
            ? (float)ns.CoveredLines / ns.TotalLines * 100 
            : 0;
        Console.WriteLine($"{ns.Namespace.PadRight(22)} | {coverage,6:F1}% | {ns.CoveredLines,6}/{ns.TotalLines}");
    }
    
    // Общая статистика
    var totalLines = coverageByNamespace.Sum(x => x.TotalLines);
    var totalCovered = coverageByNamespace.Sum(x => x.CoveredLines);
    var totalCoverage = totalLines > 0 ? (float)totalCovered / totalLines * 100 : 0;
    
    Console.WriteLine("--------------------------------------------------");
    Console.WriteLine($"Общее покрытие: {totalCoverage:F1}% ({totalCovered}/{totalLines})");
}
Этот метод извлекает информацию о покрытии из XML-отчета и представляет её в удобном для анализа виде. Но мы можем пойти дальше и интегрировать эту информацию непосредственно в наш тестовый отчет:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public class EnhancedXmlReporter : XmlReporter
{
    private XDocument _coverageReport;
    
    public EnhancedXmlReporter(string outputPath, string coveragePath = null)
        : base(outputPath)
    {
        if (!string.IsNullOrEmpty(coveragePath) && File.Exists(coveragePath))
            _coverageReport = XDocument.Load(coveragePath);
    }
    
    public override void GenerateReport(List<TestResult> results)
    {
        using (var writer = XmlWriter.Create(OutputPath, new XmlWriterSettings { Indent = true }))
        {
            writer.WriteStartDocument();
            writer.WriteStartElement("testsuites");
            
            writer.WriteStartElement("testsuite");
            writer.WriteAttributeString("name", "CustomFramework");
            writer.WriteAttributeString("tests", results.Count.ToString());
            writer.WriteAttributeString("failures", 
                results.Count(r => !r.IsSuccess).ToString());
            writer.WriteAttributeString("timestamp", 
                DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"));
            
            // Добавляем информацию о покрытии, если она доступна
            if (_coverageReport != null)
            {
                var totalLines = _coverageReport.Descendants("class")
                    .Sum(c => int.Parse(c.Attribute("lines")?.Value ?? "0"));
                var coveredLines = _coverageReport.Descendants("class")
                    .Sum(c => int.Parse(c.Attribute("coveredlines")?.Value ?? "0"));
                
                writer.WriteAttributeString("coverage", 
                    totalLines > 0 ? ((float)coveredLines / totalLines * 100).ToString("F1") + "%" : "N/A");
            }
            
            foreach (var result in results)
            {
                writer.WriteStartElement("testcase");
                writer.WriteAttributeString("name", result.TestName);
                writer.WriteAttributeString("time", 
                    result.Metrics.ExecutionTime.TotalSeconds.ToString());
                
                if (!result.IsSuccess)
                {
                    writer.WriteStartElement("failure");
                    writer.WriteAttributeString("message", result.FailureMessage);
                    writer.WriteEndElement(); // failure
                }
                
                // Если есть информация о покрытии для этого теста, добавляем её
                if (_coverageReport != null)
                {
                    var testClass = result.TestName.Split('.')[0];
                    var coverageInfo = _coverageReport.Descendants("class")
                        .FirstOrDefault(c => c.Attribute("name")?.Value == testClass);
                    
                    if (coverageInfo != null)
                    {
                        writer.WriteStartElement("coverage");
                        writer.WriteAttributeString("lines", 
                            coverageInfo.Attribute("lines")?.Value ?? "0");
                        writer.WriteAttributeString("coveredlines", 
                            coverageInfo.Attribute("coveredlines")?.Value ?? "0");
                        writer.WriteEndElement(); // coverage
                    }
                }
                
                writer.WriteEndElement(); // testcase
            }
            
            writer.WriteEndElement(); // testsuite
            writer.WriteEndElement(); // testsuites
            writer.WriteEndDocument();
        }
    }
}
Такая интеграция позволяет сопоставить каждый тест с покрываемым им кодом, что даёт более глубокое понимание эффективности тестов.

Внедрение системы оценки качества тестов



Количество тестов и процент покрытия кода — важные метрики, но они не всегда отражают реальное качество тестирования. Тесты могут покрывать код, но при этом проверять лишь "солнечные сценарии", игнорируя обработку ошибок и граничные случаи. Давайте расширим наш фреймворк инструментами для оценки качества тестов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
public class TestQualityAnalyzer
{
    private readonly TestDiscoverer _discoverer = new TestDiscoverer();
    
    public TestQualityReport AnalyzeTestQuality(Assembly testAssembly, Assembly targetAssembly)
    {
        var testClasses = _discoverer.DiscoverTests(testAssembly);
        var report = new TestQualityReport();
        
        // Анализ покрытия методов
        report.MethodCoverage = AnalyzeMethodCoverage(testClasses, targetAssembly);
        
        // Анализ разнообразия проверок
        report.AssertionDiversity = AnalyzeAssertionDiversity(testClasses);
        
        // Анализ тестирования исключений
        report.ExceptionTesting = AnalyzeExceptionTesting(testClasses);
        
        // Анализ параметризации тестов
        report.ParameterizationScore = AnalyzeParameterization(testClasses);
        
        return report;
    }
    
    private float AnalyzeMethodCoverage(List<TestInfo> testClasses, Assembly targetAssembly)
    {
        // Число публичных методов в целевой сборке
        var targetMethods = targetAssembly.GetTypes()
            .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static))
            .Where(m => !m.IsSpecialName) // Исключаем геттеры/сеттеры свойств
            .Count();
            
        if (targetMethods == 0)
            return 0;
        
        // Собираем все методы, вызываемые в тестах
        var calledMethods = new HashSet<string>();
        
        foreach (var test in testClasses)
        {
            var type = test.Type;
            // Анализируем IL-код тестового класса для выявления вызовов методов
            // Это сложная задача, которая требует использования специализированных 
            // библиотек типа Mono.Cecil для декомпиляции IL
            
            // В упрощенном виде можно использовать простой анализ строк в исходном коде
            // Но это не точный подход
        }
        
        // В реальном проекте здесь был бы более сложный алгоритм
        // Для примера возвращаем фиктивные данные
        return 0.75f;
    }
    
    private float AnalyzeAssertionDiversity(List<TestInfo> testClasses)
    {
        var assertionTypes = new HashSet<string>();
        int totalAssertions = 0;
        
        foreach (var test in testClasses)
        {
            var type = test.Type;
            // Аналогично, в реальном коде здесь был бы анализ 
            // использованных типов ассертов
        }
        
        // Коэффициент разнообразия: количество разных типов ассертов 
        // относительно общего числа ассертов
        return totalAssertions > 0 ? (float)assertionTypes.Count / totalAssertions : 0;
    }
    
    private float AnalyzeExceptionTesting(List<TestInfo> testClasses)
    {
        int testsWithExceptionHandling = 0;
        
        foreach (var test in testClasses)
        {
            var type = test.Type;
            // Проверка использования конструкций обработки исключений
            // и специальных методов типа ExceptionAssert.Throws
        }
        
        return testClasses.Count > 0 ? (float)testsWithExceptionHandling / testClasses.Count : 0;
    }
    
    private float AnalyzeParameterization(List<TestInfo> testClasses)
    {
        int parameterizedTests = 0;
        
        foreach (var test in testClasses)
        {
            if (typeof(ParameterizedTestCase<>).IsAssignableFrom(test.Type))
                parameterizedTests++;
        }
        
        return testClasses.Count > 0 ? (float)parameterizedTests / testClasses.Count : 0;
    }
}
 
public class TestQualityReport
{
    public float MethodCoverage { get; set; }
    public float AssertionDiversity { get; set; }
    public float ExceptionTesting { get; set; }
    public float ParameterizationScore { get; set; }
    
    public float OverallScore => (MethodCoverage + AssertionDiversity + ExceptionTesting + ParameterizationScore) / 4;
    
    public override string ToString()
    {
        return $"Отчет о качестве тестов:\n" +
               $"- Покрытие методов: {MethodCoverage:P1}\n" +
               $"- Разноообразие проверок: {AssertionDiversity:P1}\n" +
               $"- Тестирование исключений: {ExceptionTesting:P1}\n" +
               $"- Использование параметризации: {ParameterizationScore:P1}\n" +
               $"- Общая оценка: {OverallScore:P1}";
    }
}
Класс TestQualityAnalyzer предоставляет комплексный подход к оценке качества тестов, анализируя не только покрытие кода, но и другие важные аспекты, такие как разнообразие проверок, тестирование исключений и использование параметризации. Стоит отметить, что в реальном проекте анализ IL-кода или исходного кода для выявления взаимодействий с тестируемой системой потребует использования специализированых библиотек или инструментов, таких как Mono.Cecil или Roslyn.

Объединение всех компонентов в единую систему



Теперь, когда у нас есть все необходимые компоненты для полноценного тестирования, объединим их в единую систему, которая сможет:
1. Запускать тесты.
2. Анализировать покрытие кода.
3. Оценивать качество тестов.
4. Генерировать комплексные отчеты.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
public class TestingPlatform
{
    private readonly TestRunner _runner;
    private readonly CoverageRunner _coverageRunner;
    private readonly TestQualityAnalyzer _qualityAnalyzer;
    
    public TestingPlatform(string testAssemblyPath, string targetAssemblyPath)
    {
        _runner = new ParallelTestRunner();
        _coverageRunner = new CoverageRunner("coverage-tool.exe", targetAssemblyPath);
        _qualityAnalyzer = new TestQualityAnalyzer();
        
        // Загружаем сборки
        var testAssembly = Assembly.LoadFrom(testAssemblyPath);
        var targetAssembly = Assembly.LoadFrom(targetAssemblyPath);
        
        // Обнаруживаем и регистрируем тесты
        var discoverer = new TestDiscoverer();
        var testInfos = discoverer.DiscoverTests(testAssembly);
        
        foreach (var testInfo in testInfos)
        {
            var test = discoverer.CreateTestInstance(testInfo.Type);
            _runner.AddTest(test);
        }
    }
    
    public TestingPlatformReport RunTestSuite()
    {
        // Запускаем тесты и собираем результаты
        _runner.RunAll();
        var testResults = _runner.GetResults();
        
        // Запускаем анализ покрытия
        _coverageRunner.RunAll();
        
        // Анализируем качество тестов
        var testAssembly = Assembly.LoadFrom(testAssemblyPath);
        var targetAssembly = Assembly.LoadFrom(targetAssemblyPath);
        var qualityReport = _qualityAnalyzer.AnalyzeTestQuality(testAssembly, targetAssembly);
        
        // Генерируем комплексный отчет
        var report = new TestingPlatformReport
        {
            TestResults = testResults,
            QualityReport = qualityReport,
            CoverageReportPath = "coverage.xml"
        };
        
        // Создаем HTML-отчет
        var htmlReporter = new EnhancedHtmlReporter("test-report.html", "coverage.xml");
        htmlReporter.GenerateReport(testResults, qualityReport);
        
        return report;
    }
}
 
public class TestingPlatformReport
{
    public List<TestResult> TestResults { get; set; }
    public TestQualityReport QualityReport { get; set; }
    public string CoverageReportPath { get; set; }
    
    public bool IsSuccessful => TestResults.All(r => r.IsSuccess);
    
    public override string ToString()
    {
        var builder = new StringBuilder();
        builder.AppendLine("Отчет о выполнении тестов:");
        builder.AppendLine($"- Всего тестов: {TestResults.Count}");
        builder.AppendLine($"- Успешных: {TestResults.Count(r => r.IsSuccess)}");
        builder.AppendLine($"- Проваленных: {TestResults.Count(r => !r.IsSuccess)}");
        builder.AppendLine();
        builder.AppendLine(QualityReport.ToString());
        
        return builder.ToString();
    }
}
 
public class EnhancedHtmlReporter
{
    private readonly string _outputPath;
    private readonly string _coveragePath;
    
    public EnhancedHtmlReporter(string outputPath, string coveragePath = null)
    {
        _outputPath = outputPath;
        _coveragePath = coveragePath;
    }
    
    public void GenerateReport(List<TestResult> results, TestQualityReport qualityReport)
    {
        var sb = new StringBuilder();
        sb.AppendLine("<!DOCTYPE html>");
        sb.AppendLine("<html>");
        sb.AppendLine("<head>");
        sb.AppendLine("  <title>Комплексный отчет о тестировании</title>");
        sb.AppendLine("  <style>");
        sb.AppendLine("    .success { color: green; }");
        sb.AppendLine("    .failure { color: red; }");
        sb.AppendLine("    .skipped { color: gray; }");
        sb.AppendLine("    .quality-high { background-color: #d4edda; }");
        sb.AppendLine("    .quality-medium { background-color: #fff3cd; }");
        sb.AppendLine("    .quality-low { background-color: #f8d7da; }");
        sb.AppendLine("    table { border-collapse: collapse; width: 100%; }");
        sb.AppendLine("    th, td { border: 1px solid #ddd; padding: 8px; }");
        sb.AppendLine("    tr:nth-child(even) { background-color: #f2f2f2; }");
        sb.AppendLine("    .report-section { margin-bottom: 30px; }");
        sb.AppendLine("  </style>");
        sb.AppendLine("</head>");
        sb.AppendLine("<body>");
        
        // Раздел с общей статистикой
        sb.AppendLine("<div class='report-section'>");
        sb.AppendLine("  <h1>Комплексный отчет о тестировании</h1>");
        int total = results.Count;
        int passed = results.Count(r => r.IsSuccess);
        int failed = results.Count(r => !r.IsSuccess);
        
        sb.AppendLine("  <div class='summary'>");
        sb.AppendLine($"    <p>Всего тестов: {total}</p>");
        sb.AppendLine($"    <p class='success'>Успешных: {passed}</p>");
        sb.AppendLine($"    <p class='failure'>Проваленных: {failed}</p>");
        sb.AppendLine("  </div>");
        sb.AppendLine("</div>");
        
        // Раздел с качеством тестов
        sb.AppendLine("<div class='report-section'>");
        sb.AppendLine("  <h2>Оценка качества тестов</h2>");
        
        string qualityClass = qualityReport.OverallScore > 0.7f ? "quality-high" :
                             qualityReport.OverallScore > 0.4f ? "quality-medium" : "quality-low";
        
        sb.AppendLine($"  <div class='{qualityClass}'>");
        sb.AppendLine($"    <p>Общая оценка: {qualityReport.OverallScore:P1}</p>");
        sb.AppendLine($"    <p>Покрытие методов: {qualityReport.MethodCoverage:P1}</p>");
        sb.AppendLine($"    <p>Разнообразие проверок: {qualityReport.AssertionDiversity:P1}</p>");
        sb.AppendLine($"    <p>Тестирование исключений: {qualityReport.ExceptionTesting:P1}</p>");
        sb.AppendLine($"    <p>Использование параметризации: {qualityReport.ParameterizationScore:P1}</p>");
        sb.AppendLine("  </div>");
        sb.AppendLine("</div>");
        
        // Добавляем таблицу с результатами тестов
        sb.AppendLine("<div class='report-section'>");
        sb.AppendLine("  <h2>Результаты выполнения тестов</h2>");
        sb.AppendLine("  <table>");
        sb.AppendLine("    <tr>");
        sb.AppendLine("      <th>Тест</th>");
        sb.AppendLine("      <th>Статус</th>");
        sb.AppendLine("      <th>Время (мс)</th>");
        sb.AppendLine("      <th>Память (КБ)</th>");
        sb.AppendLine("      <th>Сообщение</th>");
        sb.AppendLine("    </tr>");
        
        foreach (var result in results)
        {
            string statusClass = result.IsSuccess ? "success" : "failure";
            
            sb.AppendLine("    <tr>");
            sb.AppendLine($"      <td>{result.TestName}</td>");
            sb.AppendLine($"      <td class='{statusClass}'>{(result.IsSuccess ? "Успех" : "Провал")}</td>");
            sb.AppendLine($"      <td>{result.Metrics.ExecutionTime.TotalMilliseconds:F1}</td>");
            sb.AppendLine($"      <td>{result.Metrics.MemoryUsedBytes / 1024:F1}</td>");
            sb.AppendLine($"      <td>{(result.IsSuccess ? "" : result.FailureMessage)}</td>");
            sb.AppendLine("    </tr>");
        }
        
        sb.AppendLine("  </table>");
        sb.AppendLine("</div>");
        
        // Если есть информация о покрытии, добавляем её
        if (File.Exists(_coveragePath))
        {
            sb.AppendLine("<div class='report-section'>");
            sb.AppendLine("  <h2>Покрытие кода</h2>");
            
            // Здесь мы бы добавили визуализацию покрытия кода,
            // например, в виде тепловой карты или графика
            
            sb.AppendLine("</div>");
        }
        
        sb.AppendLine("</body>");
        sb.AppendLine("</html>");
        
        File.WriteAllText(_outputPath, sb.ToString());
    }
}
Созданная нами TestingPlatform объединяет все ранее разработанные компоненты в единую систему, которая предоставляет комплексный подход к тестированию. Такая платформа может стать основой для корпортивного решения по обеспечению качества ПО.

Итоги нашего путешествия



Создание собственного тестовый фреймворка в C# — задача не из лёгких, но вполне реализуемая и чрезвычайно полезная. Мы прошли путь от базовых классов до сложной системы, способной анализировать качество тестов и генерировать комплексные отчеты. Наша тестовая платформа теперь включает:
  1. Основные компоненты тестового фреймворка (тестовые кейсы, раннер, ассерты).
  2. Механизмы расширения для параметризации и категоризации тестов.
  3. Систему отчетности и визуализации результатов.
  4. Интеграцию с инструментами анализа покрытия кода.
  5. Оценку качества тестов по различным метрикам.

Хотя мы и не стремились создать замену xUnit или NUnit, наше решение обладает рядом преимуществ в специфических сценариях. Глубокая интеграция с инфраструктурой проекта, специализированные ассерты для доменной логики и настраиваемые метрики качества позволяют лучше отражать особеноости конкретного проекта.

Конечно, в реальной жизни каждая команда должна взвесить все за и против перед тем, как создавать собственный тестовый фреймворк. Часто расширение существующего инструмента через плагины или обёртки может дать схожие преимущества при меньших затратах. Но иногда, когда уникальные требования проекта делают стандартные решения слишком ограничивающими, создание собственного инструмента становится не роскошью, а необходимостью. В любом случае, знание внутреннего устройства тестовых фреймворков, которое мы получили в процессе создания собственного решения, помогает лучше понять принципы автоматизированного тестирования и использовать даже стандартные инструменты более эффективно. Как говорится, чтобы по-настоящему понять инструмент, нужно уметь его создать самому.

Использование фреймворка для клиентской части
Всем привет. Пишу на C# ASP.NET. И возник вопрос. На данный момент существуют JavaScript...

Подскажите обучающие материалы по ASP.Net core 7 для минимального понимания фреймворка
Здравствуйте, я пытался найти хоть какие то материалы по обучению ASP.net 7, но нашел только...

Программа для тестирования сети. Концепция и разработка.
Дамы и господа, здравствуйте! Столкнулся с необходимостью написания одной замечательной программы...

при компиляции проэкт невидит библиотек фреймворка(Visual studio 2010)
Подключил библиотеки к проэкту путём - (нажмите правой кнопкой на «Ссылки в дереве объектов»...

Возможно ли использование классов .Net на системе без установленного фреймворка?
Доброго времени суток. Возможно ли скомпилировать экзешник таким образом, чтобы он содержал в себе...

Ошибка фреймворка: Could not copy Microsoft.Expression.DesignHost.dll
Could not copy &quot;D:\Visual 2013\Blend\Microsoft.Expression.DesignHost.dll&quot; to...

Какой процент возможностей фреймворка используете в своих проектах?
У меня этот процент довольно невысок, где-то не более 25%. Всё потому-что не нахожу применения уйме...

Обновление без ChangeTracker'a или два фреймворка в одном проекте?
Как оптимально реализовать построение запросов на частичное обновление данных, если нет дефолтного...

Не запускается приложение - не та версия фреймворка
Приложение на C#. Изначально было написано под .NET 4.7. Ввиду отсутствия .NET 4.7 на машинах, где...

Как написать приложения под все версии фреймворка
Здравствуйте. Начал изучать C# и тут встал вопрос: Как написать такое приложения, которое будет...

Разработка программы тестирования - как обработать полученный результат
Разрабатываю программу тестирования по психологии. Не могу понять как обработать результаты. Тест...

Создание собственного класса и метода для вычисления выражения
Добрый вечер. Есть программа, её нужно реализовать через метод и собственный класс. Помогите...

Метки c#, gof, oop, patterns, testing, ооп
Размещено в Без категории
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Чем асинхронная логика (схемотехника) лучше тактируемой, как я думаю, что помимо энергоэффективности - ещё и безопасность.
Hrethgir 14.05.2025
Помимо огромного плюса в энергоэффективности, асинхронная логика - тотальный контроль над каждым совершённым тактом, а значит - безусловная безопасность, где безконтрольно не совершится ни одного. . .
Многопоточные приложения на C++
bytestream 14.05.2025
C++ всегда был языком, тесно работающим с железом, и потому особеннно эффективным для многопоточного программирования. Стандарт C++11 произвёл революцию, добавив в язык нативную поддержку потоков,. . .
Stack, Queue и Hashtable в C#
UnmanagedCoder 14.05.2025
Каждый опытный разработчик наверняка сталкивался с ситуацией, когда невинный на первый взгляд List<T> превращался в узкое горлышко всего приложения. Причина проста: универсальность – это прекрасно,. . .
Как использовать OAuth2 со Spring Security в Java
Javaican 14.05.2025
Протокол OAuth2 часто путают с механизмами аутентификации, хотя по сути это протокол авторизации. Представьте, что вместо передачи ключей от всего дома вашему другу, который пришёл полить цветы, вы. . .
Анализ текста на Python с NLTK и Spacy
AI_Generated 14.05.2025
NLTK, старожил в мире обработки естественного языка на Python, содержит богатейшую коллекцию алгоритмов и готовых моделей. Эта библиотека отлично подходит для образовательных целей и. . .
Реализация DI в PHP
Jason-Webb 13.05.2025
Когда я начинал писать свой первый крупный PHP-проект, моя архитектура напоминала запутаный клубок спагетти. Классы создавали другие классы внутри себя, зависимости жостко прописывались в коде, а о. . .
Обработка изображений в реальном времени на C# с OpenCV
stackOverflow 13.05.2025
Объединение библиотеки компьютерного зрения OpenCV с современным языком программирования C# создаёт симбиоз, который открывает доступ к впечатляющему набору возможностей. Ключевое преимущество этого. . .
POCO, ACE, Loki и другие продвинутые C++ библиотеки
NullReferenced 13.05.2025
В C++ разработки существует такое обилие библиотек, что порой кажется, будто ты заблудился в дремучем лесу. И среди этого многообразия POCO (Portable Components) – как маяк для тех, кто ищет. . .
Паттерны проектирования GoF на C#
UnmanagedCoder 13.05.2025
Вы наверняка сталкивались с ситуациями, когда код разрастается до неприличных размеров, а его поддержка становится настоящим испытанием. Именно в такие моменты на помощь приходят паттерны Gang of. . .
Создаем CLI приложение на Python с Prompt Toolkit
py-thonny 13.05.2025
Современные командные интерфейсы давно перестали быть черно-белыми текстовыми программами, которые многие помнят по старым операционным системам. CLI сегодня – это мощные, интуитивные и даже. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru