Люблю свой С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)); } }
assertEquals("В книге Дугласа Адамса «Путеводитель для путешествующих автостопом по галактике»\n" "ответ на «Главный вопрос жизни, вселенной и вообще» должен был решить все проблемы Вселенной.\n" "Этого ответа с нетерпением ждали все разумные расы.\n" + "Он был получен в результате семи с половиной миллионов лет непрерывных \n" + "вычислений на специально созданном компьютере — Думателе.\n" + "По утверждению компьютера, ответ был несколько раз проверен на правильность, \n" + "но он может всех огорчить. Оказалось, что ответ на вопрос — «42».", godObject.toString());
Теперь, если у меня слетит тест, я буду видеть diff всего стейта объекта, а не малоинформативное "Expected: 42 But was: 43". Я всегда раньше думал глядя на такие ассерты как-то так:
С последующим сравниванием двух файлов (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"));
}