Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Как я тестировал свои задачи: подход опытного разработчика
Тестирование — это не завершающий этап разработки, а неотъемлемая часть процесса. Расскажу, как я структурирую свой процесс тестирования для разных типов задач.
Философия: Test-Driven Development (TDD)
Лучший подход, который я использую — это TDD:
- RED → Написать падающий тест
- GREEN → Написать код, чтобы тест прошёл
- REFACTOR → Улучшить код, сохраняя тесты зелёными
Это гарантирует, что код покрыт тестами с самого начала и соответствует требованиям.
Уровни тестирования
1. Unit-тесты (70% времени)
Это основной уровень тестирования — проверяю отдельные методы и функции изолированно:
// Класс для тестирования
public class UserService {
private UserRepository repository;
private EmailValidator validator;
public User registerUser(String email, String name) {
if (!validator.isValid(email)) {
throw new InvalidEmailException("Email is not valid");
}
if (repository.existsByEmail(email)) {
throw new EmailAlreadyExistsException("Email already registered");
}
return repository.save(new User(email, name));
}
}
// Unit-тесты (без БД)
@ExtendWith(MockitoExtension.class)
class UserServiceUnitTest {
@Mock
private UserRepository repository;
@Mock
private EmailValidator validator;
@InjectMocks
private UserService userService;
@Test
void shouldThrowWhenEmailInvalid() {
// Arrange
when(validator.isValid("invalid")).thenReturn(false);
// Act & Assert
assertThrows(InvalidEmailException.class, () ->
userService.registerUser("invalid", "John")
);
// Verify repository was NOT called
verify(repository, never()).save(any());
}
@Test
void shouldThrowWhenEmailExists() {
// Arrange
when(validator.isValid("john@example.com")).thenReturn(true);
when(repository.existsByEmail("john@example.com")).thenReturn(true);
// Act & Assert
assertThrows(EmailAlreadyExistsException.class, () ->
userService.registerUser("john@example.com", "John")
);
}
@Test
void shouldSaveUserWhenValid() {
// Arrange
when(validator.isValid("john@example.com")).thenReturn(true);
when(repository.existsByEmail("john@example.com")).thenReturn(false);
User savedUser = new User(1L, "john@example.com", "John");
when(repository.save(any(User.class))).thenReturn(savedUser);
// Act
User result = userService.registerUser("john@example.com", "John");
// Assert
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getEmail()).isEqualTo("john@example.com");
verify(repository).save(any(User.class));
}
}
2. Integration-тесты (20% времени)
Проверяю взаимодействие между компонентами, обычно с использованием реальной БД:
@SpringBootTest
class UserRepositoryIntegrationTest {
@Autowired
private UserRepository repository;
@Autowired
private TestEntityManager tem;
@Test
@Transactional
void shouldSaveAndRetrieveUser() {
// Arrange
User user = new User("john@example.com", "John Doe");
// Act
User saved = repository.save(user);
tem.flush();
tem.clear();
User retrieved = repository.findByEmail("john@example.com").orElseThrow();
// Assert
assertThat(retrieved.getId()).isEqualTo(saved.getId());
assertThat(retrieved.getName()).isEqualTo("John Doe");
}
@Test
@Transactional
void shouldNotAllowDuplicateEmails() {
// Arrange
repository.save(new User("john@example.com", "John"));
// Act & Assert
User duplicate = new User("john@example.com", "Another John");
assertThrows(DataIntegrityViolationException.class, () ->
repository.save(duplicate)
);
}
}
3. End-to-End тесты (10% времени)
Проверяю полный цикл запроса через контроллер:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository repository;
@BeforeEach
void cleanUp() {
repository.deleteAll();
}
@Test
void shouldRegisterUserSuccessfully() {
// Arrange
RegisterUserRequest request = new RegisterUserRequest(
"john@example.com",
"John Doe"
);
// Act
ResponseEntity<UserResponse> response = restTemplate.postForEntity(
"/api/v1/users/register",
request,
UserResponse.class
);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody().getEmail()).isEqualTo("john@example.com");
// Verify database
User savedUser = repository.findByEmail("john@example.com").orElseThrow();
assertThat(savedUser.getName()).isEqualTo("John Doe");
}
@Test
void shouldReturn400WhenEmailExists() {
// Arrange
repository.save(new User("john@example.com", "John"));
RegisterUserRequest request = new RegisterUserRequest(
"john@example.com",
"Another John"
);
// Act
ResponseEntity<ErrorResponse> response = restTemplate.postForEntity(
"/api/v1/users/register",
request,
ErrorResponse.class
);
// Assert
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
Мой процесс тестирования для новой задачи
Шаг 1: Понимание требований
Сначала я выясняю:
- Какое поведение нужно реализовать?
- Какие edge cases существуют?
- Какие зависимости нужно мокировать?
Шаг 2: Написание тестов (RED)
@Test
void shouldCalculateDiscountFor100Items() {
// Arrange
OrderService service = new OrderService();
Order order = new Order(100, BigDecimal.TEN); // 100 items по 10 каждый
// Act
BigDecimal total = service.calculateTotal(order);
// Assert
// 10% скидка на 100+ предметов: 100 * 10 * 0.9 = 900
assertThat(total).isEqualTo(BigDecimal.valueOf(900));
}
Шаг 3: Реализация кода (GREEN)
public class OrderService {
public BigDecimal calculateTotal(Order order) {
BigDecimal subtotal = BigDecimal.valueOf(order.getQuantity())
.multiply(order.getPrice());
// 10% скидка на 100+ предметов
if (order.getQuantity() >= 100) {
return subtotal.multiply(BigDecimal.valueOf(0.9));
}
return subtotal;
}
}
Шаг 4: Дополнительные тесты (больше покрытия)
@Test
void shouldNotApplyDiscountFor99Items() {
OrderService service = new OrderService();
Order order = new Order(99, BigDecimal.TEN);
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.valueOf(990)); // Без скидки
}
@Test
void shouldApplyDiscountFor1000Items() {
OrderService service = new OrderService();
Order order = new Order(1000, BigDecimal.TEN);
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.valueOf(9000)); // 10% скидка
}
Параметризованные тесты (для множественных случаев)
class OrderServiceParameterizedTest {
@ParameterizedTest
@CsvSource({
"50, 100, 500", // 50 items * 10 = 500 (no discount)
"99, 100, 990", // 99 items * 10 = 990 (no discount)
"100, 100, 900", // 100 items * 10 * 0.9 = 900 (10% discount)
"200, 100, 1800", // 200 items * 10 * 0.9 = 1800 (10% discount)
})
void shouldCalculateTotalCorrectly(int quantity, BigDecimal price,
BigDecimal expected) {
OrderService service = new OrderService();
Order order = new Order(quantity, price);
BigDecimal result = service.calculateTotal(order);
assertThat(result).isEqualByComparingTo(expected);
}
}
Тестирование исключений
@Test
void shouldThrowWhenQuantityNegative() {
OrderService service = new OrderService();
Order order = new Order(-10, BigDecimal.TEN);
assertThrows(IllegalArgumentException.class, () ->
service.calculateTotal(order),
"Quantity cannot be negative"
);
}
// Или с assertThatThrownBy (AssertJ)
@Test
void shouldThrowWhenPriceNull() {
OrderService service = new OrderService();
Order order = new Order(10, null);
assertThatThrownBy(() -> service.calculateTotal(order))
.isInstanceOf(NullPointerException.class)
.hasMessage("Price cannot be null");
}
Тестирование асинхронного кода
@Test
void shouldProcessOrderAsync() throws InterruptedException, ExecutionException {
OrderService service = new OrderService();
Order order = new Order(100, BigDecimal.TEN);
CompletableFuture<BigDecimal> result = service.calculateTotalAsync(order);
// Ждём результат с timeout
BigDecimal total = result.get(2, TimeUnit.SECONDS);
assertThat(total).isEqualByComparingTo(BigDecimal.valueOf(900));
}
Тестирование многопоточности
@Test
void shouldBeThreadSafe() throws InterruptedException {
OrderService service = new OrderService();
List<Order> orders = new ArrayList<>();
for (int i = 0; i < 100; i++) {
orders.add(new Order(100, BigDecimal.TEN));
}
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<?>> futures = new ArrayList<>();
for (Order order : orders) {
futures.add(executor.submit(() -> {
service.calculateTotal(order);
}));
}
for (Future<?> future : futures) {
future.get(); // Ждём завершения
}
executor.shutdown();
// Если не было исключений, тест прошёл
}
Покрытие тестами (Coverage)
Анализирую покрытие кода:
# Запуск с отчётом о покрытии
mvn clean test jacoco:report
# Откроется target/site/jacoco/index.html
# Стремлюсь к >= 80% покрытию
Мои инструменты
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.2.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>3.1.0</version>
<scope>test</scope>
</dependency>
Запуск тестов
# Все тесты
mvn test
# Конкретный класс
mvn test -Dtest=UserServiceTest
# Конкретный метод
mvn test -Dtest=UserServiceTest#shouldSaveUser
# С фильтром
mvn test -Dtest=*Service*
# В watch режиме
mvn test -f pom.xml -Dbasedir=. test
Шпаргалка: когда писать какие тесты
| Компонент | Unit | Integration | E2E |
|---|---|---|---|
| Service (бизнес-логика) | ✅ | ❌ | ❌ |
| Repository (JDBC) | ❌ | ✅ | ❌ |
| Controller (API) | ❌ | ❌ | ✅ |
| Utility функции | ✅ | ❌ | ❌ |
| Фильтры, interceptors | ❌ | ✅ | ✅ |
Мой контрольный список перед commit'ом
- ✅ Все юнит-тесты проходят
- ✅ Не менее 80% покрытия кода
- ✅ Интеграционные тесты для репозиториев
- ✅ E2E тесты для критичного функционала
- ✅ Нет падающих тестов
- ✅ Тесты быстрые (< 10 сек)
- ✅ Код готов для ревью
Тестирование — это не дополнительная работа, это инвестиция в качество и скорость разработки. Хорошо протестированный код проще менять и меньше багов.