С прошлого года я проводил множество тестов на масштабируемых архитектурах, и в какой-то момент Стив Харрис указал на принципы внедрения ошибок в обсуждении (спасибо Стиву за то, что он заставил меня задуматься над этим).
Когда я начал смотреть на это более внимательно, я понял несколько вещей:
— Модульное тестирование — это хорошо, есть несколько хороших принципов, но иногда они недостаточно четко объяснены.
— Тестирование архитектуры для масштабирования — это другая работа. Вам нужно гораздо больше, чем юнит-тестирование.
Я не совсем понял модульное тестирование.
Вы наверняка слышали, что при написании модульных тестов вы должны думать о тестировании неудачных случаев, а не рабочих.
Затем вы начали писать тесты и не думали о хорошем неудачном случае, поэтому вы написали тест для случая, который работает.
Затем, подумав, вы нашли неудачный случай (обычно кладите NPE куда-нибудь).
Затем вы привыкли к этому, и при написании модульных тестов вы рассматриваете как рабочие, так и неудачные случаи.
Раньше я делал это, и я изменился, и теперь я вижу, что я пишу лучшие приложения.
Позволь мне объяснить.
Все началось, когда я хотел добавить в свои тесты инъекции ошибок.
Что такое неисправность впрыска?
Из Википедии:
«В тестировании программного обеспечения внедрение ошибок — это метод улучшения охвата теста путем введения ошибок в пути кода теста»
Проще говоря, давайте возьмем пример:
public class TextApp {
public static void main(String[] arg) {
try {
String filename = "someFile.txt";
String content = "Let's write this in the file";
WriterService writerService = new WriterServiceImpl();
writerService.writeOnDisk(filename, content);
} catch (Exception e) {
System.out.println("We couldn't write on the disk. " + e.getMessage());
}
}
}
import java.io.IOException;
public interface WriterService {
void writeOnDisk(String filename, String content) throws Exception;
}
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class WriterServiceImpl implements WriterService {
public void writeOnDisk(final String filename, final String content) throws Exception {
try {
FileWriter file = new FileWriter(filename);
BufferedWriter out = new BufferedWriter(file);
out.write(content);
out.close();
} catch (IOException e) {
throw new Exception("file writing error", e);
}
}
}
И я хочу написать модульный тест для WriterService:
import org.junit.Assert;
import org.junit.Test;
public class WriterServiceTest {
@Test
public void testSuccessfulWriting() {
WriterService writerService = new WriterServiceImpl();
try {
writerService.writeOnDisk("test.txt", "testContent");
} catch (Exception e) {
Assert.fail("We should be able to write to the disk");
}
}
@Test
public void testFailingWriting() {
WriterService writerService = new WriterServiceImpl();
try {
writerService.writeOnDisk("impossiblefilename^&:/.txt", null);
Assert.fail("We should not be able to write to the disk");
} catch (Exception e) {
Assert.assertEquals("file writing error", e.getMessage());
}
}
}
И я сделал именно то , что я описал: написать модульный тест с рабочим случаем и неудачным случаем.
Допустим, я достаточно удовлетворен (очевидно, код здесь минимален и должен быть улучшен, но для демонстрации мы скажем, что код в WriterService удовлетворяет).
Теперь я могу улучшить охват моего теста с помощью инъекции неисправности .
Поэтому я решаю внедрить ошибки во время выполнения в сервисе при выполнении моего модульного теста.
Например, я могу вспомнить случай, когда диск заполнен , я хочу это проверить.
Но как я могу написать тест, который будет выполняться, когда диск заполнен? Ну, я собираюсь смоделировать (ввести) эту ошибку.
Существует довольно хороший API для этого , который называется Byteman , который представляет собой API-интерфейс для манипулирования байт-кодом с JUnit / TestNG, поэтому в основном при использовании этой библиотеки вы можете написать такой тест:
...
@BMRule(name="throw sync failed exception",
targetClass="FileInputStream",
targetMethod="(File)",
condition="$1.getName().equals\"diskfullname.txt\")"
action="throw new SyncFailedException(\"can't sync to disk!\")")
@Test
public void testDiskfull() {
WriterService writerService = new WriterServiceImpl();
try {
writerService.writeOnDisk("diskfullname.txt", null);
Assert.fail("We should not be able to write to the disk");
} catch (Exception e) {
Assert.assertEquals("file writing error", e.getMessage());
}
}
...
И это очень хорошо, я могу написать больше тестов, иметь более надежный код.
Но я могу сделать лучше.
Давайте на секунду забудем о Байтмане, как бы вы поступили, если бы делали это самостоятельно?
У меня есть мысль.
Я собираюсь разбить WriterService на два класса: WriterService и DiskWriterService
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class WriterServiceImpl implements WriterService {
DiskWriterService diskWriterService = new DiskWriterServiceimpl();
public void writeOnDisk(final String filename, final String content) throws Exception {
try {
diskWriterService.write(filename, content);
} catch (IOException e) {
throw new Exception("file writing error", e);
}
}
public void setDiskWriterService(final DiskWriterService diskWriterService) {
this.diskWriterService = diskWriterService;
}
}
import java.io.IOException;
public interface DiskWriterService {
void write(String filename, String content) throws IOException;
}
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
public class DiskWriterServiceimpl implements DiskWriterService {
public void write(final String filename, final String content) throws IOException {
FileWriter file = new FileWriter(filename);
BufferedWriter out = new BufferedWriter(file);
out.write(content);
out.close();
}
}
Разделение службы на две части добавляет существенное преимущество моему приложению: оно делает его более свободным. Мне, вероятно, не нужно подробно объяснять, что такое слабая связь, в основном это идея разбить приложение на компоненты, которым не нужно знать состояние других компонентов для работы.
С этим я могу написать следующий класс:
import java.io.IOException;
public class FaultyDiskWriterServiceImpl implements DiskWriterService {
private IOException exception;
public void write(final String filename, final String content) throws IOException {
throw exception;
}
public void setException(final IOException exception) {
this.exception = exception;
}
}
и тогда я могу заменить свой тестовый пример следующим:
@Test
public void testDiskfull() {
WriterServiceImpl writerService = new WriterServiceImpl();
FaultyDiskWriterServiceImpl faultyDiskWriterService = new FaultyDiskWriterServiceImpl();
faultyDiskWriterService.setException(new SyncFailedException("disk full"));
writerService.setDiskWriterService(faultyDiskWriterService);
try {
writerService.writeOnDisk("diskfullname.txt", null);
Assert.fail("We should not be able to write to the disk");
} catch (Exception e) {
Assert.assertEquals("file writing error", e.getMessage());
}
}
Итак, давайте подумаем на минуту, что я сделал?
— Я написал неудачный тестовый пример (диск заполнен)
— Это заставило меня разбить мой WriterService на две части, и это хорошо, потому что это делает мое приложение более свободно связанным . Если у меня есть другой сервис, который нужно записать на диск, я мог бы повторно использовать этот DiskWriterService
— Теперь я могу выполнить модульное тестирование моего DiskService и убедиться в надежности записи на диск .
Он разделяет тестирование записи на диск и тестирование моего WriterService (сейчас это просто случай, когда он вызывает DiskWriterService, но он может делать больше вещей, таких как регистрация, запись в несколько мест…).
— С помощью этого нового DiskWriterService это неявно делает мою архитектуру более устойчивой к масштабируемости , если когда-нибудь мне понадобится записать на более чем один диск или другой тип хранилища (CloudDiskWriter, QueueDiskWriter…), я мог бы написать другую реализацию DiskWriterService, не касаясь остальных кода.
Так что теперь я более четко вижу смысл тестирования неудачных случаев. Если вы не знаете, когда делаете это, я бы посоветовал: «Подумайте об ошибках, которые вы могли бы добавить в свой код при написании модульных тестов».
Вот и все для пункта 1, в следующий раз для пункта 2 — Тестирование на масштабирование!