15 декабря 2014

Встраиваем тесты в приложение или интеграционное тестирование с хохломой и гимназистками

Преамбула.


Как-то летом один персонаж сказал, что он собирается встроить тесты внутрь самого приложения. Тогда я молча поржал про себя.
Спустя 3 месяца на Agile Testing Days я побывал на докладе, где рассказывалось о том что Runtastic делал так для того чтобы выявить багу на устройствах пользователей.
The developers decided to build a unit test into the app where they could check the result of the device’s calculation for a well-known distance between two coordinates.
(Чуть более подробно можно прочесть тут, но большая часть отводится рекламе)
Собственно с того самого доклада мысль зудела в голове и вылилась в пару часов  пляски с кодом.

Амбула.

Начнем мы конечно же с первого по важности для аудитории этого уютненького бложека вопроса - нахеразачем оно нужно?

Если вам не достаточно кейса Runtastic приведенного выше, то отвечу.
Если у вас все хорошо, production вам подконтролен, конфигурация зафиксирована и проблем нет - оно вам не нужно.
Но не у всех и не всегда оно так.
Примеры когда оно не так:

  1.  Вы разрабатываете что-то что будет ставится в непонятное окружение или контейнер и вам нужно понимать что окружение соответствует.
  2. Окружение в которое вы ставите свое поделие может динамически меняться и об этом тоже было бы неплохо знать.

В общем и целом оба вопроса выше сводятся к вопросу о доверии к reference implementation чего-то что вам дают снаружи ( в случае с Runtastic таким reference implementation-ом было API  по работе с GPS видимо).
В зависимости от того насколько большому куску мы не доверяем может захотеться написать как Unit-тест, так и интеграционный.
Рассмотрим оба случая :).
Естественно я буду рассматривать это все на Java потому что режиссер так видит (с)  мне так нравится, но все показанное ниже также справедливо и реализуемо для всех остальных промышленных языков программирования.

Покажи мне свой код.



Код собственно на гитхабе.
Клонируем, в консоли запускаем mvn exec:java, видим такое.

[INFO] ------------------------------------------------------------------------
[INFO] Building embedded-unittests 1.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- exec-maven-plugin:1.3.2:java (default-cli) @ embedded-unittests ---
[WARNING] Warning: killAfter is now deprecated. Do you need it ? Please comment on MEXEC-6.
2014-12-09 16:11:19.748:INFO:oejs.Server:jetty-8.1.9.v20130131
2014-12-09 16:11:19.784:INFO:oejs.AbstractConnector:Started SelectChannelConnector@0.0.0.0:8080
view raw gistfile1.txt hosted with ❤ by GitHub
Дальше идем в браузере идем на урлы:
http://127.0.0.1:8080/runUnitTests - для запуска Unit тестов

Видим такое

Total 2 Failures 1
testB(test.TestB): java.lang.AssertionError
at org.junit.Assert.fail(Assert.java:86)
at org.junit.Assert.assertTrue(Assert.java:41)
at org.junit.Assert.assertTrue(Assert.java:52)
at test.TestB.testB(TestB.java:12)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:606)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:70)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:50)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runners.Suite.runChild(Suite.java:127)
at org.junit.runners.Suite.runChild(Suite.java:26)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runner.JUnitCore.run(JUnitCore.java:160)
at org.junit.runner.JUnitCore.run(JUnitCore.java:138)
at UnitTestRunner.doGet(UnitTestRunner.java:28)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:735)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:848)
at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:669)
at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:457)
at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1075)
at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:384)
at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1009)
at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:135)
at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:116)
at org.eclipse.jetty.server.Server.handle(Server.java:368)
at org.eclipse.jetty.server.AbstractHttpConnection.handleRequest(AbstractHttpConnection.java:488)
at org.eclipse.jetty.server.AbstractHttpConnection.headerComplete(AbstractHttpConnection.java:932)
at org.eclipse.jetty.server.AbstractHttpConnection$RequestHandler.headerComplete(AbstractHttpConnection.java:994)
at org.eclipse.jetty.http.HttpParser.parseNext(HttpParser.java:640)
at org.eclipse.jetty.http.HttpParser.parseAvailable(HttpParser.java:235)
at org.eclipse.jetty.server.AsyncHttpConnection.handle(AsyncHttpConnection.java:82)
at org.eclipse.jetty.io.nio.SelectChannelEndPoint.handle(SelectChannelEndPoint.java:628)
at org.eclipse.jetty.io.nio.SelectChannelEndPoint$1.run(SelectChannelEndPoint.java:52)
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:608)
at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:543)
at java.lang.Thread.run(Thread.java:744)
view raw gistfile1.txt hosted with ❤ by GitHub

http://127.0.0.1:8080/runIntegrationTests - для запуска интеграционных тестов.

Видим такое
Total 2 Failures 1
testA(itest.ITestA): expected:<[a]> but was:<[b]>org.junit.ComparisonFailure: expected:<[a]> but was:<[b]>
at org.junit.Assert.assertEquals(Assert.java:115)
at org.junit.Assert.assertEquals(Assert.java:144)
at itest.ITestA.testA(ITestA.java:19)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:57)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:606)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:47)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:44)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:271)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:70)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:50)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runners.Suite.runChild(Suite.java:127)
at org.junit.runners.Suite.runChild(Suite.java:26)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:238)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:63)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:236)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:53)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:229)
at org.junit.runners.ParentRunner.run(ParentRunner.java:309)
at org.junit.runner.JUnitCore.run(JUnitCore.java:160)
at org.junit.runner.JUnitCore.run(JUnitCore.java:138)
at IntegrationTestRunner.doGet(IntegrationTestRunner.java:36)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:735)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:848)
at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:669)
at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:457)
at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1075)
at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:384)
at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1009)
at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:135)
at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:116)
at org.eclipse.jetty.server.Server.handle(Server.java:368)
at org.eclipse.jetty.server.AbstractHttpConnection.handleRequest(AbstractHttpConnection.java:488)
at org.eclipse.jetty.server.AbstractHttpConnection.headerComplete(AbstractHttpConnection.java:932)
at org.eclipse.jetty.server.AbstractHttpConnection$RequestHandler.headerComplete(AbstractHttpConnection.java:994)
at org.eclipse.jetty.http.HttpParser.parseNext(HttpParser.java:640)
at org.eclipse.jetty.http.HttpParser.parseAvailable(HttpParser.java:235)
at org.eclipse.jetty.server.AsyncHttpConnection.handle(AsyncHttpConnection.java:82)
at org.eclipse.jetty.io.nio.SelectChannelEndPoint.handle(SelectChannelEndPoint.java:628)
at org.eclipse.jetty.io.nio.SelectChannelEndPoint$1.run(SelectChannelEndPoint.java:52)
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:608)
at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:543)
at java.lang.Thread.run(Thread.java:744)
view raw gistfile1.txt hosted with ❤ by GitHub
Посмотреть в код под капотом (кому и если будет интересно) вы сможете сами.

Мысли вслух.

Unit-тесты встроенные в приложение позволят вам отловить исключительно мелкие вещи. Вреда от того что они встроены в само приложение не должно быть никакого, разве что только если у вас их там миллион и вы все их будете гонять на production-среде.

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

Второй интересный момент который я нашел именно в такой реализации заключается в том, что подключение реальных кусков бизнес-логики к интеграционным тестам может помочь ловить баги, которые воспроизводятся только в определенных условиях.
Минусы тоже имеются - если в ваши слои бизнес-логики можно вносить изменения в runtime, то запуск таких  интеграционных тестов может положить вам ваш production.
Еще более просто и конкретно - такая штука может положить нафиг весь ваш prod и данные.
И да - подобного рода трюки не предусматривают никакой защиты от этого кроме code review.


Третье - такой подход тянет за собой все зависимости необходимые для тестов в основной продукт - то есть всякие junit, mockito, hamcrest. Ну и код тестов конечно.
Избежать этого можно - в своем примере я развел это через профили сборки maven.
Единственная корявость которая есть - приходится указывать значение свойства по умолчанию для исключаемых при компиляции пакетов.

При обычной сборке (mvn clean package) с такой настройкой содержимое архива будет выглядеть так.
А при релизной (mvn clean package -P release) вот так

По аналогии можно сделать и с зависимостями - их скоуп тоже можно разрулить через профили сборки.


P.S. Как и обещал.
Хохлома.
Гимназистки.


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

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