Если нельзя, но очень хочется, то нужно обязательно и ничего в мире не стоит того, чтобы делать из этого проблему!


Интересна Java? Кликай по ссылке и изучай!
Если тебе полезно что-то из того, чем я делюсь в своем блоге - можешь поделиться своими деньгами со мной.
с пожеланием
столько времени читатели провели на блоге - 
сейчас онлайн - 

суббота, 8 апреля 2023 г.

Новый вид unit тестирования - Directive Approvals Testing

Люблю свой Сodenjoy проект потому, что могу в нем пробовать новые подходы в разработке. Сегодня хочу продемонстрировать один из них. Ведь именно так и рождаются инженерные практики: ты экпериментируешь на своей кухне => что-то показывает хороший результат => ты это осознаешь => публикуешь открытие => его уносят в массы.

Вот пример использования одного кастомного малого, которого я научил бегать по разным jar расположенным как в класспасе, так и за пределами проекта (рядом с war например в папке с плагинами).

@Configuration
public class MVCConf implements WebMvcConfigurer {

    @Bean
    @SneakyThrows
    public ResourceHttpRequestHandler resourceHttpRequestHandler(ServletContext servletContext) {
        return new ResourceHttpRequestHandler() {{
            setCacheControl(getCache());

            setLocations(Arrays.asList(
                    // only for testing so that you can get resources from src/target folder
                    new UrlResource("file:../games/*/src/main/**"),
                    new UrlResource("file:src/main/**"),
                    new UrlResource("file:target/classes/**"),
                    // production code
                    new ServletContextResource(servletContext, "/resources/"),
                    new ClassPathResource("classpath:/resources/"),
                    new ClassPathResource("classpath*:**/resources/"),
                    new UrlResource("file:" + pluginsStatic),
                    new UrlResource("file:" + pluginsResources)));

            setResourceResolvers(Arrays.asList(new JarPathResourceResolver()));
        }};
    }

Тут можно заметить один интересный сайд эффект. Если во время разработки натравить его на папку src/target (чтобы он искал сперва там), то можно будет править скрипты и без пересборки приложения видеть изменения в браузере. Это секономит тебе дни времени за недели разработки. Достаточно будет отключить кеши в браузере или нажать Ctrl-F5.

Но вернемся к тестам для этого малого. Ничего примечательного. Все как обычно. Достаточно хорошо читаемо. Только меня смущает тот самый CopyPast который я всегда делал, чтобы создать базу для нового теста. А раз есть CopyPast, значит есть пространство для Рефакторинга.

public class JarPathResourceResolverTest {

    private JarPathResourceResolver resolver = new JarPathResourceResolver();

    @SneakyThrows
    public String load(Resource resource)  {
        return StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8);
    }

    @Test
    public void shouldLoadFromServletContext() {
        // when
        Resource resource = resolver.getResource("file1.txt",
                new ServletContextResource(new MockServletContext(), "/resolver/"));

        // then
        assertEquals("ServletContext resource [/resolver/file1.txt]", resource.toString());
        assertEquals("one", load(resource));
    }

    @Test
    public void shouldLoadFromClasspath() {
        // when
        Resource resource = resolver.getResource("file2.txt",
                new ClassPathResource("classpath:/resolver/"));

        // then
        assertEquals("class path resource [resolver/file2.txt]", resource.toString());
        assertEquals("two", load(resource));
    }

    @Test
    public void shouldLoadFromClasspathIncludingJars() {
        // when
        Resource resource = resolver.getResource("NOTICE",
                new ClassPathResource("classpath*:META-INF/"));

        // then
        assertMatch("URL [jar:file:*.jar!/META-INF/NOTICE]", resource.toString());
        assertEquals(true, resource.exists());
    }

    @Test
    public void shouldLoadFromFileSystem() throws Exception {
        // when
        Resource resource = resolver.getResource("file3.txt",
                new UrlResource("file:src/test/resources/resolver/"));

        // then
        assertEquals("URL [file:src/test/resources/resolver/file3.txt]", resource.toString());
        assertEquals("three", load(resource));
    }

    @Test
    public void shouldLoadFromJarsInFileSystem_case1() throws Exception {
        // when
        Resource resource = resolver.getResource("file4.txt",
                new UrlResource("file:src/test/resources/resolver/*.jar!/resources/**/"));

        // then
        assertMatch("URL [jar:file:*/server/src/test/resources/resolver/jar4.jar!/resources/subfolder/file4.txt]", resource.toString());
        assertEquals("four", load(resource));
    }

    @Test
    public void shouldLoadFromJarsInFileSystem_case2() throws Exception {
        // when
        Resource resource = resolver.getResource("file5.txt",
                new UrlResource("file:src/test/resources/resolver/*.jar!/resources/**/"));

        // then
        assertMatch("URL [jar:file:*/server/src/test/resources/resolver/jar5.jar!/resources/file5.txt]", resource.toString());
        assertEquals("five", load(resource));
    }
}

Не секрет, что я люблю использовать в своем проекте approvals подход. Единожды подглянутый на какой-то конференции много лет назад в одноименной библиотеке я сразу осознал, что мы с ним на долго. Больше не будет никаких assertEquals(42, godObject.getMainQuestion()); и последующих медитирований над одиноко слетевшим jUnit ассертом без понимания "а что там дальше было-то". Нет, я просто возьму и допишу объекту toString() метод (если не могу на проде - сделаю toString(godObject) в процедурном стиле прям в тестовом классе) и далее буду:
assertEquals("В книге Дугласа Адамса «Путеводитель для путешествующих автостопом по галактике»\n"
             "ответ на «Главный вопрос жизни, вселенной и вообще» должен был решить все проблемы Вселенной.\n"
             "Этого ответа с нетерпением ждали все разумные расы.\n" + 
             "Он был получен в результате семи с половиной миллионов лет непрерывных \n" + 
             "вычислений на специально созданном компьютере — Думателе.\n" + 
             "По утверждению компьютера, ответ был несколько раз проверен на правильность, \n" + 
             "но он может всех огорчить. Оказалось, что ответ на вопрос — «42».", godObject.toString());

Теперь, если у меня слетит тест, я буду видеть diff всего стейта объекта, а не малоинформативное "Expected: 42 But was: 43". Я всегда раньше думал глядя на такие ассерты как-то так:

На основе этого approvals подхода сделано много кастомных решений в проекте. Для Smoke тестов например я использую даже стенографию каких-то важных мне аспектов системы в плоский файл. 

С последующим сравниванием двух файлов (expected / actual) с помощью встроенной в IDE diff тулы. Конечно этот файл никогда не правится вручную - я лишь смотрю на изменения которые он мне подсветил и либо approve (отсюда название подхода approvals) их либо лезу в код править что-то, что я не учел. 

Так, пока diff не будет таким, который я ожидаю. Лишь тогда я смогу старый expected файл заменить новым actual, сгенерированным во время последнего запуска. Его я и закоммичу как новый expected слепок. 

Но вернемся к инсайту сегодняшнего дня. Базируясь на этом подходе я пошел дальше и захотел переписать юнит тест в какой-то такой вид. 

Все это может чуть-чуть напугать. Ведь во-первых формат непонятный. Во вторых посмотрите на эти строчки - кто будет потом суппортить такой длинный ассерт? Но спешу успокоить - я никогда не буду править этот текст ручками - буду копировать результат из diff тулзы в случае исправления и вставлять его между двух кавычек "". В этом суть approvals подхода. Я смотрю diff - я вижу, что отклик теста на мои исправления в системе адекватен, и я применяю actual как новый expected. 

А формат прост: причина=>следствие=>следствие.

[SERVLET|CLASSPATH|URL] location file-to-find=>resource.toString()|NULL=>file-content|NULL|EXISTS

Каждая такая строчка одновременно и инструкция как запускать тестируемый класс (SERVLET - используй ServletContextResource, CLASSPATH - ClassPathResource, URL - UrlResource), и директивы как проверять результат (NULL - ожидается что ресурс не найден, EXISTS - не грузим файл полностью, а только проверяем его существование). 

А если в блоке resource.toString() встречается "*" - то использовать не assertEquals, а assertMatch, который проверит соответсвует ли строчка "qwertyu" заявленному паттерну "qw*yu", что тоже удобно - потому как resource.toString() выдает часто полный путь включая c:\\java\\... что сделает тесты чувствительными не только к системе но и к местоположению проекта. 

Короче сделал свой DSL и запаковал его прямиком в expected текст. Магия подхода в том, что выполнение этих команд сгенерирует точно такой же по формату actual блок команд, только с подставленными runtime результатами. А IDE останется только показать diff этих двух версий. И напомню, если мне понравится actual, я скопипащу его в expected теста. Если нет - я отправлюсь в код программы и буду править. 

Например я тут я сразу вижу, что ServletContextResource работает, а вот с ClassPathResource и UrlResource беда - они все не хотят искать файлы.

А вот тут я вижу, что как-то криво контактенируется искомый файл и контекст в котором ищем и там появляется лишний слеш, а потому некоторые кейзы поиска не отрабатывают

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

Захотел я это все потому, что мне захотелось проверить еще надцать вариантов запусков с разными комбинациями параметров, чтобы на 100% убедиться, что все отрабатывает окей. Но делать надцать раз CopyPast это черезчур. Потому я сделал небольшой тестовый фреймворк прям в этом тесте. Да он сейчас выглядит сильно менее читабельно.

Опять же, в чистом approvals подходе я никогда не пишу этот весь контент сам. Я запускаю пустой ассерт.
    @Test
    public void shouldAddTrainingSlash() {
        assertAll("");
    }

Далее копирую Actual из diff тулы IDE и вставляю его в пустые кавычки, но только если вижу, что там все ок. Если не ок - одно из двух: либо тестовый фреймворк с ошибкой, либо я нашел ошибку в продакшен коде. 

Но в новом подходе надо указать какие-то директивы к запуску. Это обязывает использовать свой паттерн. Благо если уже есть какие-то наработки то Ctrl-D и скопировать строчку и поправить не сложно. А когда есть какой-то сет - дальше пользуемся CopePast Actual => Expected.

Кстати вот код, который генерирует это все безобразие. 

    private void assertAll(String data) {
        assertEquals(data,
                Arrays.stream(data.split("\n"))
                        .peek(line -> log.info(String.format("Processing: '%s'\n", line)))
                        .map(line -> line.split("=>"))
                        .map(array -> array[0] + "=>" + call(array[0], array[1], array[2]))
                        .collect(joining("\n")) + "\n");
    }

    private String call(String request, String expectedResource, String expectedContent) {
        String[] parts = request.split(" ");
        String type = parts[0];
        String location = parts[1];
        String file = parts[2];

        // when
        JarPathResourceResolver resolver = new JarPathResourceResolver();
        Resource resource = resolver.getResource(file, withResource(type, location));

        // then
        return String.format("%s=>%s",
                getResource(expectedResource, resource),
                getContent(expectedContent, resource));
    }

    @SneakyThrows
    private Resource withResource(String type, String location) {
        switch (type) {
            case "SERVLET":
                return new ServletContextResource(new MockServletContext(), location);
            case "CLASSPATH":
                return new ClassPathResource(location);
            case "URL":
                return new UrlResource(location);
            default:
                throw new IllegalArgumentException("Unknown type: " + type);
        }
    }

    private String getResource(String expectedResource, Resource resource) {
        if (expectedResource.contains("*") && isMatch(expectedResource, toString(resource))) {
            return expectedResource;
        }
        return toString(resource);
    }

    private String toString(Resource resource) {
        if (resource == null) {
            return "NULL";
        }
        return resource.toString().replaceAll("\\\\", "/");
    }

    private String getContent(String content, Resource resource) {
        if (resource == null) {
            return "NULL";
        }
        String exists = resource.exists() ? "EXISTS" : "NOT EXISTS";
        return content.equals("EXISTS") ? exists : load(resource);
    }

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

Ах да! Надо назвать как-то этот подход. Имя твое - Directive Approvals Testing.

Все это было написано среди ночи под приятные слуху колебания воздуха в Patrik Pietschmann. Отдельно хочу отметить эту композицию другого Автора. Такое количества нот я не видел даже у Рахманинова, хотя тут больше гаммы, а у Раманинова тотальный рендом )

Кстстати небольшой бонус для тех, кто дочитал до этого места. Пример assertMatch как расширение assertEquals с проверками типа qwertyu == qw*yu. Очень полезно, если надо из expected блока скрыть часть текста, который либо очень недетерминированный либо выдает какие-то особенности dev-окружения закреплять которые в тесте конечно же не стоит.

    public static boolean isMatch(String expectedPattern, String actual) {
String[] patterns = expectedPattern.split("\\*", -1);

String first = patterns[0];
if (!first.isEmpty()
&& !actual.startsWith(first))
{
return false;
}

String last = patterns[patterns.length - 1];
if (patterns.length > 1
&& !last.isEmpty()
&& !actual.endsWith(last))
{
return false;
}

int pos = 0;
for (String pattern : patterns) {
int index = actual.indexOf(pattern, pos);
if (index < pos) {
return false;
}
pos = index + pattern.length();
}

return true;
} public static void assertMatch(String pattern, String actual) { if (!isMatch(pattern, actual)) { assertEquals(pattern, actual); } }
И тесты для него
    @Test
public void testIsMatch_case1() {
assertEquals(true, isMatch("qwe-asd-*", "qwe-asd-zxc"));
assertEquals(false, isMatch("qwe-Asd-*", "qwe-asd-zxc"));
assertEquals(true, isMatch("*-asd-zxc", "qwe-asd-zxc"));
assertEquals(false, isMatch("*-aSd-zxc", "qwe-asd-zxc"));
assertEquals(true, isMatch("*-asd-*", "qwe-asd-zxc"));
assertEquals(false, isMatch("*-asd-*A", "qwe-asd-zxc"));
assertEquals(true, isMatch("*-*-*", "qwe-asd-zxc"));
assertEquals(false, isMatch("A*-*-*", "qwe-asd-zxc"));
assertEquals(true, isMatch("*", "qwe-asd-zxc"));
assertEquals(false, isMatch("*A", "qwe-asd-zxc"));
assertEquals(true, isMatch("qwe-*-zxc", "qwe-asd-zxc"));
assertEquals(false, isMatch("qwe-A*-zxc", "qwe-asd-zxc"));
assertEquals(true, isMatch("q*-*-zx*-wer", "qwe-asd-zxc-wer"));
assertEquals(false, isMatch("q*-*-Zx*-wer", "qwe-asd-zxc-wer"));

assertEquals(true, isMatch("a*c", "abbbc"));
assertEquals(false, isMatch("a*b", "abbbc"));
assertEquals(true, isMatch("*a*", "banana"));
assertEquals(true, isMatch("*a*", "ananas"));
assertEquals(true, isMatch("**a*", "ananas"));
assertEquals(true, isMatch("*a**", "ananas"));
assertEquals(true, isMatch("**a**", "ananas"));
assertEquals(true, isMatch("*a*b", "ab"));
assertEquals(true, isMatch("*a*b*", "acbabcab"));
assertEquals(true, isMatch("a*", "a"));
assertEquals(true, isMatch("a", "a"));
assertEquals(true, isMatch("*a", "ba"));
assertEquals(true, isMatch("*", ""));
assertEquals(true, isMatch("a*b", "acbabcab"));
assertEquals(true, isMatch("a**b", "acbabcab"));
assertEquals(true, isMatch("a***b", "acbabcab"));
assertEquals(false, isMatch("b*", "ab"));
assertEquals(false, isMatch("b**", "ab"));
assertEquals(false, isMatch("*a", "b"));
assertEquals(false, isMatch("**a", "b"));
assertEquals(false, isMatch("*a*b", "abbc"));
assertEquals(true, isMatch("*a*b*", "acbabc"));
assertEquals(true, isMatch("*a*c", "abbbc"));
assertEquals(true, isMatch("*a*c*", "acbabc"));
}
Приятного аппетита! 
 
Ах да, этот код/и тесты был сгенерирован вредным ChatGPT, который все никак не хотел слушаться. Ему помог GithubCopilot в двух корнер кейзах. Будь внимателен. LLM моделям снится как они видели как кто-то кодировал когда-то. Именно этот "сон" ты видишь во время общения с ними. В их "коде" и любых утверждениях сосредоточено много ошибок, хотя сказано это с умным видом лица. Я бы сказал 60-80% информации верны - что уже очень-очень неплохо. На остальные % пиши тесты. Много тестов не бывает. О моем опыте промптинга я раскажу в другом посте. 

Ах да, и делай Self Code Review перед каждым своим коммитом. Так, на всякий.

Комментариев нет:

Отправить комментарий