Статьи

Помогите, мой код не тестируется! Нужно ли исправить дизайн?

Наш код часто непроверяем, потому что нет простого способа «ощутить 1 » результатов хорошим способом и потому что код зависит от внешних данных / функциональности, не позволяя заменить или изменить их во время теста (в нем отсутствует шов 2). то есть место, где поведение кода может быть изменено без изменения самого кода). В таких случаях лучше всего исправить конструкцию, чтобы сделать код тестируемым, а не пытаться написать хрупкое и медленное интеграционное тестирование. Давайте посмотрим пример такого кода и как это исправить.

Пример дизайна спагетти

Следующий код представляет собой REST-подобную службу, которая выбирает список файлов из Amazon Simple Storage Service (S3) и отображает их в виде списка ссылок на содержимое файлов:

public class S3FilesResource {

    AmazonS3Client amazonS3Client;

    // ...

    @Path("/files")
    public String listS3Files() {
        StringBuilder html = new StringBuilder("<html><body>");
        List<S3ObjectSummary> files = this.amazonS3Client.listObjects("myBucket").getObjectSummaries();
        for (S3ObjectSummary file : files) {
            String filePath = file.getKey();
            if (!filePath.endsWith("/")) { // exclude directories
                html.append("<a href='/content?fileName=").append(filePath).append("'>").append(filePath)
                    .append("</a><br>");
            }
        }
        return html.append("</body></html>").toString();
    }

    @Path("/content")
    public String getContent(@QueryParam("fileName") String fileName) {
        throw new UnsupportedOperationException("Not implemented yet");
    }

}

Почему код трудно проверить?

  1. Нет шва, который позволил бы нам обойти внешнюю зависимость от S3, и поэтому мы не можем влиять на то, какие данные передаются в метод, и не можем легко проверить их с другими значениями. Кроме того, мы зависим от сетевого подключения и правильного состояния в сервисе S3, чтобы иметь возможность выполнять код.
  2. Трудно ощутить результат метода, потому что он смешивает данные с их представлением. Было бы намного проще иметь прямой доступ к данным, чтобы убедиться, что каталоги исключены и что отображаются ожидаемые имена файлов. Более того, основная логика может измениться гораздо реже, чем HTML-презентация, но изменение презентации сломает наши тесты, даже если логика не изменится.

Что мы можем сделать, чтобы улучшить это?

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

Начнем с написания простого теста:

public class S3FilesResourceTest {

    @Test
    public void listFilesButNotDirectoriesAsHtml() throws Exception {
        S3FilesResource resource = new S3FilesResource(/* pass AWS credentials ... */);
        String html = resource.listS3Files();
        assertThat(html)
            .contains("<a href='/content?fileName=/dir/file1.txt'>/dir/file1.txt</a>")
            .contains("<a href='/content?fileName=/dir/another.txt'>/dir/another.txt</a>")
            .doesNotContain("/dir/</a>"); // directories should be excluded
        assertThat(html.split(quote("</a>"))).hasSize(2 + 1); // two links only
    }

}

Рефакторинг дизайна

Это измененный дизайн, в котором я отделил код от S3, представив Facade / Adapter и разделив обработку и рендеринг данных:

public interface S3Facade {
    List<S3File> listObjects(String bucketName);
}
public class S3FacadeImpl implements S3Facade {

    AmazonS3Client amazonS3Client;

    @Override
    public List<S3File> listObjects(String bucketName) {
        List<S3File> result = new ArrayList<S3File>();
        List<S3ObjectSummary> files = this.amazonS3Client.listObjects(bucketName).getObjectSummaries();
        for (S3ObjectSummary file : files) {
            result.add(new S3File(file.getKey(), file.getKey())); // later we can use st. else for the display name
        }
        return result;
    }

}

 

public class S3File {
    public final String displayName;
    public final String path;

    public S3File(String displayName, String path) {
        this.displayName = displayName;
        this.path = path;
    }
}

 

public class S3FilesResource {

    S3Facade amazonS3Client = new S3FacadeImpl();

    // ...

    @Path("/files")
    public String listS3Files() {
        StringBuilder html = new StringBuilder("<html><body>");
        List<S3File> files = fetchS3Files();
        for (S3File file : files) {
            html.append("<a href='/content?fileName=").append(file.path).append("'>").append(file.displayName)
                    .append("</a><br>");
        }
        return html.append("</body></html>").toString();
    }

    List<S3File> fetchS3Files() {
        List<S3File> files = this.amazonS3Client.listObjects("myBucket");
        List<S3File> result = new ArrayList<S3File>(files.size());
        for (S3File file : files) {
            if (!file.path.endsWith("/")) {
                result.add(file);
            }
        }
        return result;
    }

    @Path("/content")
    public String getContent(@QueryParam("fileName") String fileName) {
        throw new UnsupportedOperationException("Not implemented yet");
    }

}

На практике я хотел бы рассмотреть возможность использования встроенных возможностей преобразования в Джерси (с настраиваемым  MessageBodyWriter для HTML) и возврата List <S3File> из listS3Files.

Вот как выглядит тест сейчас:

public class S3FilesResourceTest {

    private static class FakeS3Facade implements S3Facade {
        List<S3File> fileList;

        public List<S3File> listObjects(String bucketName) {
            return fileList;
        }
    }

    private S3FilesResource resource;
    private FakeS3Facade fakeS3;

    @Before
    public void setUp() throws Exception {
        fakeS3 = new FakeS3Facade();
        resource = new S3FilesResource();
        resource.amazonS3Client = fakeS3;
    }

    @Test
    public void excludeDirectories() throws Exception {
        S3File s3File = new S3File("file", "/file.xx");
        fakeS3.fileList = asList(new S3File("dir", "/my/dir/"), s3File);
        assertThat(resource.fetchS3Files())
            .hasSize(1)
            .contains(s3File);
    }

    /** Simplest possible test of listS3Files */
    @Test
    public void renderToHtml() throws Exception {
        fakeS3.fileList = asList(new S3File("file", "/file.xx"));
        assertThat(resource.listS3Files())
            .contains("/file.xx");
    }
}

Затем я бы реализовал интеграционный тест для службы REST, но по-прежнему использовал FakeS3Facade, чтобы убедиться, что служба работает и достижима по ожидаемому URL-адресу и что ссылка на содержимое файла также работает. Я также написал бы интеграционный тест для реального клиента S3 (через S3FilesResource, но без запуска его на сервере), который будет выполняться только по требованию, чтобы убедиться, что наши учетные данные S3 верны и что мы можем достичь S3. (Я не хочу выполнять его регулярно, так как в зависимости от внешнего сервиса он медленный и ломкий.)

Отказ от ответственности: служба выше не является хорошим примером правильного использования REST, и я взял несколько кратких ссылок, которые не представляют хороший код для краткости.

1 ) Представлено Майклом Фезерсом в « Эффективной работе с устаревшим кодексом» , стр. 20-21.
2 ) Там же, стр. 31.