Статьи

Спланируйте масштабируемую архитектуру с помощью внедрения ошибок

С прошлого года я проводил множество тестов на масштабируемых архитектурах, и в какой-то момент Стив Харрис указал на принципы внедрения ошибок в обсуждении (спасибо Стиву за то, что он заставил меня задуматься над этим).

Когда я начал смотреть на это более внимательно, я понял несколько вещей:

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

Я не совсем понял модульное тестирование.
Вы наверняка слышали, что при написании модульных тестов вы должны думать о тестировании неудачных случаев, а не рабочих.
Затем вы начали писать тесты и не думали о хорошем неудачном случае, поэтому вы написали тест для случая, который работает.
Затем, подумав, вы нашли неудачный случай (обычно кладите 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 — Тестирование на масштабирование!