В этой статье описываются некоторые практики для тестирования многопоточных и распределенных приложений, написанных на Java. Пример, над которым я работал и который мы будем использовать, представляет собой одноранговое приложение, состоящее из множества узлов (клиентов) и нескольких суперузлов (серверов).
Конечная цель состоит в том, чтобы создать приложение, состоящее из всех этих объектов, но первые тесты нацелены на суперузел, обслуживающий один или несколько узлов.
Ходячий скелет
TDD в основном повторяется, но нуждается в отправной точке. Самая простая история, которую мы можем придумать, это история Узла, соединяющегося со Суперузлом.
Клиент и серверы обычно работают в своих собственных потоках (в случае сервера — несколько), но изначально объект Node может быть просто POJO и выполняться в потоке теста, потому что нам пока не нужно управлять несколькими узлами.
Вместо этого объект Supernode является Thread (или Runnable), и поэтому мы уже сталкиваемся с упрощенной версией проблемы синхронизации: как убедиться, что Supernode готов ответить на соединения после того, как мы запустили его поток?
Тест JUnit выглядит следующим образом:
@Test public void aNodeCanConnectToASupernode() throws Exception { Supernode supernode = new Supernode(8888); supernode.start(); supernode.ensureStartupIsFinished(); Node n = new Node(); n.connect("127.0.0.1", 8888); assertEquals(1, supernode.getNodes()); }
supernode.start () запускает новый поток, в то время как вызов функции sureStartupIsFinished () должен будет блокироваться, пока другой поток не будет готов. Затем мы создаем объект Node и говорим ему подключаться; после завершения этой операции мы подсчитываем, сколько узлов подключилось к суперузлу.
Чтобы выполнить этот тест, Supernode может быть однопоточным сервером:
public class Supernode extends Thread { private int port; private boolean startupCompleted; private int nodes = 0; public Supernode(int port) { this.port = port; this.startupCompleted = false; } public void run() { ServerSocket sock; try { sock = new ServerSocket(this.port); // ...networking setup... synchronized (this) { this.startupCompleted = true; notify(); } while (true) { // ...accepting new connections on sock and other stuff } } public int getNodes() { return nodes; } synchronized public void ensureStartupIsFinished() throws InterruptedException { while (!this.startupCompleted) { wait(); } } }
Что в этом первом примере?
- Объектами потоков можно управлять как POJO из одной JVM: если мы напишем их с помощью этого API, их будет легко создавать и завершать, а также добавлять примитивы для синхронизации.
- Поле startupCompleted , которое является примером такого поведения синхронизации, добавленного в производственный код. Добавление производственного кода только для целей сквозного тестирования не является редкостью.
Тестовый поток блокируется внутри sureStartupIsFinished (), пока он не проснется через уведомление. Даже в этом случае startupCompleted должен иметь значение true, иначе он будет ждать больше. Это обычная синхронизация Java: обратите внимание на синхронизированные блоки вокруг this.wait () и this.notify (). Проблема с фреймворками и контейнерами заключается в том, что вы должны надеяться, что они предоставляют средства синхронизации для тестирования вашего кода, как только он внутри них: пытались ли вы когда-нибудь ждать запуска Tomcat ?
В этом коде есть некоторые заметные недостающие части:
- потоки для каждого узла. Текущий тест не требует их, так как на данный момент подключается только один узел.
- Вызов Thread.sleep (): по крайней мере, для тех счастливых путей, о которых я говорил до сих пор, мне никогда не нужно представлять их и считать их запахом.
- Конфигурационные файлы: если бы нам пришлось читать конфигурацию, тестирование заняло бы очень много времени и постоянно ссылалось бы на внешние ресурсы. Это тот случай, когда тестирование выполняется с помощью внешних инструментов, которые нельзя встраивать (Tomcat требует файлы конфигурации, а Jetty позволяет передавать конфигурацию в тестовом коде Java). Вы всегда можете добавить конфигурацию на основе файлов позже, но сейчас это замедлит нас.
эволюция
Добавляя один тест за раз с большей областью, мы можем попытаться развить код и добавить сложную сетевую многопоточную часть по одному биту за раз.
После нескольких итераций тест становится:
public class FileSharingNetworkTest { Supernode supernode; @Before public void setUp() throws Exception { supernode = new Supernode(8888); supernode.start(); supernode.ensureStartupIsFinished(); } @After public void tearDown() throws Exception { supernode.ensureStop(); } @Test public void aNodeCanConnectToASupernode() throws Exception { Node n = newNode(Arrays.asList("1.txt", "2.txt")); n.ensureConnectionIsFinished(); assertEquals(1, supernode.getNodes()); assertEquals(2, supernode.getDocuments()); } @Test public void multipleNodesCanConnectToASupernodeSimultaneously() throws Exception { Node n1 = newNode(); Node n2 = newNode(); n1.ensureConnectionIsFinished(); n2.ensureConnectionIsFinished(); assertEquals(2, supernode.getNodes()); } private Node newNode() { Node n = new Node("127.0.0.1", 8888); n.start(); n.setDocumentList(Arrays.asList("1.txt", "2.txt")); return n; } private Node newNode(List<String> documentList) { Node n = new Node("127.0.0.1", 8888); n.start(); n.setDocumentList(documentList); return n; } }
Код на стороне сервера еще не имеет нескольких потоков. Какой тест-кейс их вызовет? Вы должны найти это и написать это. Этот рабочий процесс обеспечит наличие теста, предназначенного для этого случая. В моем случае это был первый тест, требующий взаимодействия между двумя клиентами, когда один должен был увидеть документы, перечисленные другим, после того, как оба подключились.
Даже если вы знаете, где вы окажетесь, вы можете протестировать реализацию: преимущество в том, что вы лучше понимаете стандартную конструкцию и гарантируете ее охват тестированием. После еще нескольких тестов я достиг многопоточного сервера с основным потоком и chidren для управления соединениями; и Node объекты реализованы как независимые потоки.
Выводы
При работе с TDD в масштабе системы, включающем асинхронное поведение, мы должны стремиться к комплекту тестов, который:
- быстро ; даже при ожидании нескольких потоков выполнение одного сквозного теста может занять менее секунды.
- Комплексный ; TDD заставляет нас писать только проверенный код, а не копировать фрагменты из Интернета.
- Надежный : полностью детерминированный, так как каждый прогон будет либо проходить, либо проваливаться, даже если он повторяется десятки раз. Не должно быть никаких спящих призывов ко всем счастливым путям; в систему должны быть встроены средства синхронизации и остановки.
- Благодаря модульным тестам : наряду с сквозными тестами мы должны написать модульные тесты для объектов, которые нам нужно извлечь (и это будет однопоточный). Мне было легко увязать в том, чтобы охватывать все больше и больше дел полнофункциональным тестом, но модульные тесты лучше показывают, где находится ошибка.
Мы также должны помнить, как проектировать наши объекты и интерфейсы:
- не начинается с N потоков, но не более чем на 1 больше, чем у теста (сервер или удаленный узел).
- Их развитие: добавление нескольких строк подробного сетевого кода Java каждый раз . Мой пример развился до N клиентских потоков, основного потока сервера и N дочерних потоков сервера, взаимодействующих с каждым клиентом. Теперь мне придется развить его до сети суперузлов, так как речь идет о сети обмена файлами; ввести безопасные каналы и сертификаты. Трудная часть заключается в постоянном рефакторинге для поддержки новых историй, без необходимости переделки всей системы для одной.
- Не только извлечение методов (автоматизированная операция), но и извлечение интерфейсов и наиболее важных объектов ; нацеливание на самые длинные и сложные классы и разделение их на основные обязанности.