6.08. Юнит тестирование
Юнит тестирование
Что такое юнит-тестирование
Юнит-тестирование — это уровень проверки программного обеспечения, направленный на верификацию отдельных единиц кода: отдельных функций, методов, классов или структур, рассматриваемых как автономные логические компоненты. Это наименьший из всех уровней тестирования по гранулярности; выше располагаются интеграционное, системное и приёмочное тестирование. Юнит-тестирование выполняется разработчиком на этапе написания кода и, в идеале, становится неотъемлемой частью цикла разработки — одновременным с написанием основной логики.
Слово юнит (от англ. unit — «единица») не имеет строгого формального определения, зависящего от языка или архитектуры. В процедурных языках юнитом часто является функция. В объектно-ориентированных — отдельный публичный метод класса или весь класс при условии его замкнутости. В функциональных языках — чистая функция. Ключевое требование: юнит должен быть изолируемым. Изоляция означает, что его поведение может быть проверено без необходимости запускать всю систему или обращаться к внешним ресурсам.
Цель юнит-тестирования — подтверждение корректности реализации ожидаемого поведения для заданного набора входных условий. Тест проверяет: «при таких-то входных данных и внутреннем состоянии модуль возвращает именно такой результат, как было заявлено в его спецификации». Таким образом, юнит-тест — это форма исполняемой спецификации: он фиксирует что должно происходить, как именно это реализовано и проверяется.
Эффективное юнит-тестирование снижает стоимость сопровождения кодовой базы. Когда модуль покрыт тестами, разработчик получает уверенность в том, что изменения, вносимые в код, не нарушают уже существующей функциональности. Это особенно важно при рефакторинге: без тестов каждое изменение сопряжено с необходимостью ручной проверки всех сценариев, включая граничные и исключительные. С тестами — достаточно запустить набор и убедиться в отсутствии регрессий.
Основные принципы юнит-тестирования
Изоляция
Каждый юнит-тест проверяет одну логическую единицу в контролируемом окружении. Это означает, что все зависимости юнита (другие классы, сервисы, внешние интерфейсы) должны быть заменены на заглушки (stubs) или моки (mocks), поведение которых задаётся явно. Например, если тестируемый метод обращается к репозиторию данных, в тесте вместо реального подключения к СУБД используется имитация репозитория, возвращающая заранее известный набор записей. Изоляция гарантирует, что падение теста связано именно с логикой тестируемого юнита, а не с сбоем внешней системы.
Детерминированность
Тест должен выдавать один и тот же результат при каждом запуске при неизменном коде и начальных условиях. Недопустимо, чтобы тест проходил «иногда» из-за случайных факторов: времени суток, порядка итерации по хеш-таблице, состояния глобальной переменной, случайного числа и так далее. Недетерминированные тесты быстро теряют доверие — их приходится перезапускать вручную, а в CI они становятся источником ложных срабатываний.
Быстрота выполнения
Юнит-тесты должны выполняться за минимальное время — в идеале, суммарное время всех юнит-тестов в проекте не должно превышать нескольких секунд. Если тест требует подключения к сети, создания файла, запуска контейнера или ожидания таймера — это уже не юнит-тест. Такие проверки относятся к интеграционному уровню. Быстрота позволяет запускать тесты постоянно: после каждого сохранения файла, перед коммитом, в рамках pre-push хука. Это создаёт «немедленную обратную связь», критически важную для поддержания качества.
Независимость тестов
Каждый тест должен быть независим от других: порядок их выполнения не должен влиять на результат. Запрещено, чтобы один тест изменял глобальное состояние (например, статическую переменную), от которого зависит другой. После завершения каждого теста окружение должно быть приведено в исходное состояние — явно (через методы tearDown / AfterEach) или неявно (благодаря изоляции и отсутствию побочных эффектов). Это требование особенно актуально при параллельном запуске тестов.
Читаемость и сопровождаемость
Код теста — такой же продукт, как и основной код. Он должен быть написан с учётом будущего сопровождения: имена тестовых методов должны точно отражать проверяемое поведение, а структура — следовать стандартному шаблону Given-When-Then («дано — когда — тогда») или Arrange-Act-Assert («подготовка — действие — проверка»). Плохо написанный тест — хуже, чем его отсутствие: он создаёт ложное чувство защищённости и затрудняет диагностику при падении.
Границы применимости юнит-тестирования
Юнит-тестирование не является универсальным решением. Оно эффективно для проверки:
- чистой логики: вычислений, преобразований, условных ветвлений, циклов;
- корректности обработки входных данных: валидных, граничных, ошибочных;
- соблюдения контрактов: предусловий, постусловий, инвариантов;
- поведения в исключительных ситуациях: выброса ожидаемых исключений.
Оно неэффективно или нецелесообразно для проверки:
- взаимодействия с внешними системами (БД, API, файловая система) — здесь уместны интеграционные тесты;
- пользовательского интерфейса — для этого применяются end-to-end-тесты;
- производительности, масштабируемости, отказоустойчивости — это предмет нагрузочного и стресс-тестирования;
- глобальной согласованности состояния системы — требует сквозных сценариев.
Важно понимать: юнит-тесты не заменяют другие виды проверок. Они формируют первый и самый надёжный слой защиты, но без верхних слоёв (интеграционных, системных) нельзя говорить о полноценной проверке программного обеспечения.
Процесс написания юнит-теста
Типичный юнит-тест проходит несколько фаз, поддерживаемых фреймворком:
-
Подготовка (Arrange / Setup)
На этом этапе создаются необходимые для теста объекты: тестируемый экземпляр (system under test), заглушки зависимостей, входные данные. Подготовка может выполняться один раз на весь набор тестов (@BeforeAll/[OneTimeSetUp]) или перед каждым тестом (@BeforeEach/[SetUp]). Рекомендуется минимизировать объём подготовки, чтобы избежать скрытых зависимостей между тестами. -
Действие (Act)
Выполняется вызов тестируемого метода или функции с заданными аргументами. Этот этап должен быть максимально лаконичным — обычно одна строка. Любые побочные эффекты, возникающие в результате вызова, должны быть зафиксированы для последующей проверки. -
Проверка (Assert)
Сравнивается фактический результат (возвращённое значение, изменённое состояние объекта, выброшенное исключение) с ожидаемым. Утверждения должны быть точными: «равно 42»; «строка равна "OK"». Использование неточных проверок снижает ценность теста. -
Завершение (Teardown / Cleanup)
Освобождаются ресурсы, восстанавливается окружение. В современных фреймворках эта фаза часто не требуется благодаря автоматическому управлению памятью и изоляции, но может быть полезна при работе с внешними ресурсами (например, временными файлами в интеграционных тестах).
Даже в простейшем случае все эти фазы присутствуют — явно или неявно. Пропуск одной из них (например, многократное использование одного и того же мока без сброса его состояния) приводит к хрупким, ненадёжным тестам.
Связь с практиками разработки
Юнит-тестирование органично вписывается в современные методологии:
-
Test-Driven Development (TDD) предполагает написание теста до реализации логики. Это заставляет разработчика сначала чётко сформулировать ожидаемое поведение, а затем «довести» код до прохождения теста. Цикл «красный → зелёный → рефакторинг» создаёт естественный темп работы и минимизирует избыточность кода.
-
Refactoring (рефакторинг) становится безопасным только при наличии хорошего покрытия юнит-тестами. Без них изменение структуры кода без изменения поведения — рискованная операция.
-
Continuous Integration (CI) полагается на быстрые и надёжные юнит-тесты как на «стражей ворот»: если они не проходят, сборка отклоняется, и проблема обнаруживается на ранней стадии.
Фреймворки
JUnit 5 (Java)
JUnit остаётся стандартом де-факто в экосистеме Java благодаря глубокой интеграции в инструментальную цепочку и устойчивому сообществу. JUnit 5 (Jupiter) представляет собой перепроектирование с нуля. Архитектура разделена на три независимых модуля:
- JUnit Platform — основа выполнения тестов, независимая от конкретного стиля тестирования. Позволяет запускать не только Jupiter-тесты, но и, например, Spock-спецификации через адаптеры.
- JUnit Jupiter — собственно API и движок для написания и выполнения тестов. Поддерживает параметризованные тесты (
@ParameterizedTest), условное выполнение (@EnabledOnOs,@DisabledIf), вложенные классы (@Nested) для логической группировки, а также кастомные расширения черезExtensionAPI. - JUnit Vintage — совместимость с JUnit 3 и 4, необходимая для постепенной миграции.
Ключевое отличие от JUnit 4 — отказ от статических методов жизненного цикла в пользу инстансных (но с сохранением @BeforeAll как static). Это позволяет инъектировать зависимости в методы, а не только в поля. JUnit 5 также вводит понятие dynamic tests — тестов, генерируемых во время выполнения, что полезно при тестировании наборов данных, читаемых из внешнего источника.
Важно: JUnit 5 не предоставляет встроенного механизма мокинга. Для этого используются сторонние библиотеки, наиболее распространённая — Mockito, которая тесно интегрируется с Jupiter через @ExtendWith(MockitoExtension.class).
NUnit и xUnit.net (.NET)
Эти два фреймворка — результат рефлексии над недостатками предыдущих поколений. NUnit (3.x) сохраняет традиционную модель с атрибутами [SetUp], [TearDown], [OneTimeSetUp], [OneTimeTearDown], [TestCase] для параметризации. Он гибок, но допускает потенциальные анти-паттерны: например, использование [SetUp] для инициализации моков, что может приводить к их неявному совместному использованию между тестами.
xUnit.net — реакция на эти проблемы. Его авторы (включая создателей NUnit) сформулировали чёткий принцип: каждый тест — независимый экземпляр класса. Конструктор используется для подготовки (Arrange), а освобождение ресурсов — через IDisposable. Атрибуты [SetUp] и [TearDown] отсутствуют: их наличие в NUnit, по мнению разработчиков xUnit, поощряет написание тестов с побочными эффектами и скрытыми зависимостями.
xUnit вводит понятие theory — теста, который должен проходить для любого входного набора, соответствующего заданным условиям ([Theory], [InlineData], [MemberData]). Это ближе к математическому определению инварианта, чем к проверке конкретных примеров.
Для мокинга в .NET-экосистеме доминирует Moq — библиотека, построенная на выражениях (Expression<T>), что даёт преимущество в типобезопасности и отладке по сравнению с рефлексией. NSubstitute предлагает более лаконичный синтаксис, но менее строгий контроль.
PyTest (Python)
PyTest — независимая реализация, ставшая фактическим стандартом благодаря своей выразительности и расширяемости. Главное отличие — отказ от наследования от TestCase. Тесты пишутся как обычные функции, что упрощает композицию и повторное использование.
Центральный механизм — фикстуры (@pytest.fixture). Это функции, возвращающие объекты, жизненный цикл которых управляется фреймворком (создание, кэширование, очистка). Фикстуры могут иметь разную область действия (scope="function", scope="class", scope="module", scope="session"), что позволяет избежать избыточной инициализации. В отличие от setUp() в unittest, фикстуры объявляются декларативно как параметры тестовой функции — это делает зависимости явными.
PyTest автоматически обнаруживает тесты по соглашению: функции, имена которых начинаются с test_, и файлы, имена которых начинаются или заканчиваются на test. Параметризация реализуется через @pytest.mark.parametrize, где каждый параметр задаётся как кортеж входных данных и ожидаемого результата — это стимулирует тестирование через таблицы эквивалентности.
Встроенная поддержка assert rewriting позволяет использовать стандартный оператор assert вместо специфичных методов (self.assertEqual). При падении теста PyTest показывает детальное сравнение структур, включая diff для строк и деревьев.
Jest (JavaScript/TypeScript)
Jest создан с учётом специфики фронтенд-разработки: динамической природы модулей, асинхронности и необходимости изоляции глобального состояния (например, DOM). Его ключевая особенность — sandboxing: каждый тестовый файл выполняется в изолированном окружении, что исключает утечки состояния между файлами.
Snapshot testing фиксирует сериализованное представление вывода (например, React-компонента) и при последующих запусках сравнивает его с сохранённой «снимковой» версией. Это эффективно для контроля неожиданных изменений в UI-логике, но требует ручного аудита при обновлении снимков.
Jest предоставляет глобальные моки через jest.mock(), которые заменяют импортируемые модули до загрузки тестируемого кода. Это позволяет изолировать даже глубоко вложенные зависимости без досрочного внедрения через конструктор.
Для асинхронных тестов Jest поддерживает async/await, возврат Promise, а также колбэки (done). Он автоматически ждёт завершения всех микрозадач и макрозадач перед завершением теста — что критично для корректной проверки таймеров и сетевых вызовов.
Mocha + Chai (JavaScript/Node.js)
Mocha — это фреймворк для запуска, а не полный стек. Он предоставляет:
- гибкую систему хуков (
before,beforeEach,after,afterEach); - поддержку асинхронных тестов (возврат
Promise, колбэкdone,async/await); - генерацию отчётов в различных форматах (spec, dot, json, junit);
- расширяемость через reporters и interfaces (BDD, TDD, QUnit-стиль).
Но он не содержит встроенных утверждений. Для этого используется Chai — библиотека, предлагающая три стиля:
assert— процедурный, похож на JUnit;should— цепочка через прототипObject.prototype;expect— цепочка черезexpect(value).to..., наиболее популярный.
Mocha особенно удобен при тестировании систем, где важна настройка окружения (например, инициализация базы данных перед всем набором тестов). Однако отсутствие встроенных моков и утверждений требует ручной сборки стека (часто с Sinon.js для мокинга).
RSpec (Ruby)
RSpec — результат применения идей Behavior-Driven Development к юнит-тестированию. Хотя BDD формально относится к уровню приёмочных тестов, RSpec показал, что язык спецификаций может быть полезен и на уровне кода.
Структура теста в RSpec — иерархия describe (контекст) → context (подконтекст) → it (пример поведения). Это позволяет выразить «при пустом входном списке метод возвращает 0, а при наличии элементов — их сумму». Такой подход превращает тесты в исполняемую документацию.
Встроенный DSL для мокинга (allow(obj).to receive(:method).and_return(value)) интегрирован в синтаксис и не требует внешних библиотек. RSpec также поддерживает shared examples — переиспользуемые наборы тестов для проверки одинакового поведения у разных объектов (например, всех реализаций интерфейса).
PHPUnit (PHP)
PHPUnit — эталонный фреймворк, входящий в рекомендации PHP-FIG и поддерживающий PSR-стандарты. Он следует традиционной модели TestCase с методами setUp() и tearDown(). Для мокинга используется встроенный createMock() или сторонние библиотеки (Prophecy, Mockery).
Особенность PHPUnit — поддержка data providers: метод, возвращающий массив наборов данных, который связывается с тестом через аннотацию @dataProvider. Это позволяет отделить логику теста от данных.
PHPUnit интегрирован в основные фреймворки: в Laravel используется TestCase, наследующий от PHPUnit; в Symfony — дополнительные утверждения для HTTP-клиента и контейнера.
Практический кейс: юнит-тестирование валидатора электронной почты
Общая спецификация поведения
Допустим, в системе требуется базовая, но надёжная валидация email-адресов по следующим правилам:
- Адрес не может быть
nullили пустой строкой — ошибка: «Email не может быть пустым». - Адрес должен содержать ровно один символ
@. - Часть до
@(локальная) не должна быть пустой. - Часть после
@(доменная) должна содержать хотя бы одну точку и не начинаться/заканчиваться ею.
Это упрощённая, но практически значимая модель (без учёта IDN, кириллических доменов, RFC 5322 в полном объёме — что оправдано для вводного примера).
C# (xUnit.net + FluentAssertions)
Тестируемый код
public record ValidationResult(bool IsValid, string? ErrorMessage = null);
public static class EmailValidator
{
public static ValidationResult Validate(string? email)
{
if (string.IsNullOrWhiteSpace(email))
return new ValidationResult(false, "Email не может быть пустым");
var parts = email.Split('@');
if (parts.Length != 2)
return new ValidationResult(false, "Email должен содержать ровно один символ '@'");
var (local, domain) = (parts[0], parts[1]);
if (string.IsNullOrEmpty(local))
return new ValidationResult(false, "Локальная часть email не может быть пустой");
if (string.IsNullOrEmpty(domain) || !domain.Contains('.') ||
domain.StartsWith('.') || domain.EndsWith('.'))
return new ValidationResult(false, "Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею");
return new ValidationResult(true);
}
}
Тесты
using Xunit;
using FluentAssertions; // рекомендуемая библиотека для выразительных утверждений
public class EmailValidatorTests
{
[Theory]
[InlineData("user@example.com")]
[InlineData("test.email+tag@sub.domain.co.uk")]
[InlineData("a@b.c")]
public void Validate_ValidEmail_ReturnsSuccess(string email)
{
// Act
var result = EmailValidator.Validate(email);
// Assert
result.IsValid.Should().BeTrue();
result.ErrorMessage.Should().BeNull();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_NullOrWhitespace_ReturnsEmptyError(string email)
{
var result = EmailValidator.Validate(email);
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Be("Email не может быть пустым");
}
[Theory]
[InlineData("missing-at.com")]
[InlineData("double@@example.com")]
[InlineData("@domain.com")]
public void Validate_InvalidAtCount_ReturnsAtError(string email)
{
var result = EmailValidator.Validate(email);
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Be("Email должен содержать ровно один символ '@'");
}
[Theory]
[InlineData("user@")]
[InlineData("user@.com")]
[InlineData("user@com.")]
[InlineData("user@com")]
public void Validate_InvalidDomain_ReturnsDomainError(string email)
{
var result = EmailValidator.Validate(email);
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Be("Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею");
}
[Fact]
public void Validate_EmptyLocalPart_ReturnsLocalError()
{
var result = EmailValidator.Validate("@example.com");
result.IsValid.Should().BeFalse();
result.ErrorMessage.Should().Be("Локальная часть email не может быть пустой");
}
}
Почему так
- Использован
recordдляValidationResult— неизменяемый DTO, идеален для возврата из чистых функций. - Параметризация группируется по семантическим категориям ошибок, а не просто по набору строк — это улучшает читаемость и упрощает сопровождение.
FluentAssertionsдаёт цепочку.Should().Be..., что ближе к естественному языку и снижает вероятность опечаток в сравнениях.- Отдельный
[Fact]для случая@example.com, потому что он одновременно нарушает два правила (пустая локальная часть + недопустимый домен), но должна сработать первая проверка — это проверка порядка условий.
Python (PyTest + встроенные утверждения)
Тестируемый модуль (email_validator.py)
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class ValidationResult:
is_valid: bool
error_message: Optional[str] = None
def validate_email(email: Optional[str]) -> ValidationResult:
if not email or not email.strip():
return ValidationResult(is_valid=False, error_message="Email не может быть пустым")
parts = email.split('@')
if len(parts) != 2:
return ValidationResult(
is_valid=False,
error_message="Email должен содержать ровно один символ '@'"
)
local, domain = parts
if not local:
return ValidationResult(
is_valid=False,
error_message="Локальная часть email не может быть пустой"
)
if not domain or '.' not in domain or domain.startswith('.') or domain.endswith('.'):
return ValidationResult(
is_valid=False,
error_message="Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею"
)
return ValidationResult(is_valid=True)
Тесты (test_email_validator.py)
import pytest
from email_validator import validate_email, ValidationResult
@pytest.mark.parametrize("email", [
"user@example.com",
"test.email+tag@sub.domain.co.uk",
"a@b.c",
])
def test_validate_valid_email_returns_success(email):
# Act
result = validate_email(email)
# Assert
assert result == ValidationResult(is_valid=True)
@pytest.mark.parametrize("email", [None, "", " "])
def test_validate_null_or_whitespace_returns_empty_error(email):
result = validate_email(email)
assert result == ValidationResult(
is_valid=False,
error_message="Email не может быть пустым"
)
@pytest.mark.parametrize("email", [
"missing-at.com",
"double@@example.com",
"@domain.com",
])
def test_validate_invalid_at_count_returns_at_error(email):
result = validate_email(email)
assert result == ValidationResult(
is_valid=False,
error_message="Email должен содержать ровно один символ '@'"
)
@pytest.mark.parametrize("email", [
"user@",
"user@.com",
"user@com.",
"user@com", # без точки
])
def test_validate_invalid_domain_returns_domain_error(email):
result = validate_email(email)
assert result == ValidationResult(
is_valid=False,
error_message="Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею"
)
def test_validate_empty_local_part_returns_local_error():
result = validate_email("@example.com")
assert result == ValidationResult(
is_valid=False,
error_message="Локальная часть email не может быть пустой"
)
Пояснения
- Использован
@dataclass(frozen=True)— неизменяемый результат, позволяющий сравнивать объекты по значению (через==). - Проверка
assert result == ValidationResult(...)— прямое сравнение ожидаемого и фактического объекта. Это возможно благодаряfrozen=Trueи автоматически сгенерированному__eq__. - Нет необходимости в сторонних assertion-библиотеках — стандартный
assertв PyTest достаточно выразителен для таких объектов. - Параметризация организована так же, как в C# — по семантическим группам.
Java (JUnit 5 + AssertJ)
Тестируемый класс
import java.util.Objects;
public final class EmailValidator {
public static final class ValidationResult {
private final boolean isValid;
private final String errorMessage;
private ValidationResult(boolean isValid, String errorMessage) {
this.isValid = isValid;
this.errorMessage = errorMessage;
}
public static ValidationResult success() {
return new ValidationResult(true, null);
}
public static ValidationResult error(String message) {
return new ValidationResult(false, message);
}
public boolean isValid() { return isValid; }
public String errorMessage() { return errorMessage; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ValidationResult that = (ValidationResult) o;
return isValid == that.isValid &&
Objects.equals(errorMessage, that.errorMessage);
}
@Override
public int hashCode() {
return Objects.hash(isValid, errorMessage);
}
}
public static ValidationResult validate(String email) {
if (email == null || email.trim().isEmpty()) {
return ValidationResult.error("Email не может быть пустым");
}
String[] parts = email.split("@", -1); // -1 сохраняет пустые части
if (parts.length != 2) {
return ValidationResult.error("Email должен содержать ровно один символ '@'");
}
String local = parts[0];
String domain = parts[1];
if (local.isEmpty()) {
return ValidationResult.error("Локальная часть email не может быть пустой");
}
if (domain.isEmpty() || !domain.contains(".") ||
domain.startsWith(".") || domain.endsWith(".")) {
return ValidationResult.error("Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею");
}
return ValidationResult.success();
}
}
Тесты
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.*;
class EmailValidatorTest {
@ParameterizedTest
@ValueSource(strings = {
"user@example.com",
"test.email+tag@sub.domain.co.uk",
"a@b.c"
})
void validate_validEmail_returnsSuccess(String email) {
var result = EmailValidator.validate(email);
assertThat(result).isEqualTo(EmailValidator.ValidationResult.success());
}
@ParameterizedTest
@ValueSource(strings = {"", " "})
void validate_emptyOrWhitespace_returnsEmptyError(String email) {
var result = EmailValidator.validate(email);
var expected = EmailValidator.ValidationResult.error("Email не может быть пустым");
assertThat(result).isEqualTo(expected);
}
@Test
void validate_nullInput_returnsEmptyError() {
var result = EmailValidator.validate(null);
var expected = EmailValidator.ValidationResult.error("Email не может быть пустым");
assertThat(result).isEqualTo(expected);
}
@ParameterizedTest
@ValueSource(strings = {
"missing-at.com",
"double@@example.com",
"@domain.com"
})
void validate_invalidAtCount_returnsAtError(String email) {
var result = EmailValidator.validate(email);
var expected = EmailValidator.ValidationResult.error(
"Email должен содержать ровно один символ '@'"
);
assertThat(result).isEqualTo(expected);
}
@ParameterizedTest
@ValueSource(strings = {
"user@",
"user@.com",
"user@com.",
"user@com"
})
void validate_invalidDomain_returnsDomainError(String email) {
var result = EmailValidator.validate(email);
var expected = EmailValidator.ValidationResult.error(
"Некорректный домен: должен содержать точку и не начинаться/заканчиваться ею"
);
assertThat(result).isEqualTo(expected);
}
@Test
void validate_emptyLocalPart_returnsLocalError() {
var result = EmailValidator.validate("@example.com");
var expected = EmailValidator.ValidationResult.error(
"Локальная часть email не может быть пустой"
);
assertThat(result).isEqualTo(expected);
}
}
Пояснения
ValidationResultреализован какstatic final classс фабричными методами и неизменяемыми полями — это стандартный паттерн для value-объектов в Java.- Переопределены
equalsиhashCode, чтобы поддерживать сравнение по значению (иначеassertThat(...).isEqualTo(...)не сработает). - Для
nullвыделен отдельный тест —@ValueSourceне поддерживаетnull, и это правильное решение: проверкаnull— отдельная семантическая категория. - Использован
split("@", -1), чтобы корректно обрабатывать@domain.com(иначеsplitбез limit вернул бы массив длины 1).