С прошлого года я проводил множество тестов на масштабируемых архитектурах, и в какой-то момент Стив Харрис указал на принципы внедрения ошибок в обсуждении (спасибо Стиву за то, что он заставил меня задуматься над этим).
Когда я начал смотреть на это более внимательно, я понял несколько вещей:
— Модульное тестирование — это хорошо, есть несколько хороших принципов, но иногда они недостаточно четко объяснены.
— Тестирование архитектуры для масштабирования — это другая работа. Вам нужно гораздо больше, чем юнит-тестирование.
Я не совсем понял модульное тестирование.
Вы наверняка слышали, что при написании модульных тестов вы должны думать о тестировании неудачных случаев, а не рабочих.
Затем вы начали писать тесты и не думали о хорошем неудачном случае, поэтому вы написали тест для случая, который работает.
Затем, подумав, вы нашли неудачный случай (обычно кладите 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 — Тестирование на масштабирование!