В последних двух статьях о Споке я рассказывал о
насмешках и
окурках . И я был довольно продан на Споке, основываясь только на этом. Но для драйвера базы данных есть особенность:
тестирование на основе данных .
Все разработчики склонны думать и проверять счастливый путь. Не в последнюю очередь потому, что это обычно путь в пользовательской истории — «Как клиент, я хочу снять деньги и иметь правильную сумму в своих руках». Мы склонны не спрашивать «что произойдет, если они попросят снять деньги, когда в банкомате нет наличных денег?» или «что происходит, когда баланс их счета равен нулю?»
Если вам повезет, у вас будет набор тестов, охватывающий ваши счастливые пути и, вероятно, как минимум вдвое больше сварливых путей. Если вы похожи на меня, и вам нравится один тест, чтобы проверить одну вещь (а кто нет?), Иногда ваши тестовые классы могут затянуться, если вы тестируете различные крайние случаи. Или, что гораздо хуже (и я тоже так делал), вы используете расчет, удивительно похожий на тот, который вы тестируете, для генерации тестовых данных. Вы запускаете свой тест в цикле с расчетом и вот! Тест проходит. Woohoo?
Не так давно я прошел процесс переписывания множества модульных тестов, которые я написал год или два назад — мы собирались сделать большой рефакторинг кода, который генерировал несколько важных чисел, и мы хотели, чтобы наши тесты чтобы сказать нам, что мы ничего не сломали с рефактором. Единственная проблема заключалась в том, что в тестах использовался расчет, довольно похожий на расчет производства, и заимствовал некоторые константы для создания ожидаемого числа. В итоге я запустил тесты, чтобы найти числа, которые генерировал тест, как ожидаемые значения, и жестко запрограммировал эти значения в тесте. Это было грязно, но это было необходимо — я хотел убедиться, что рефакторинг не изменил ожидаемые числа, а также числа, сгенерированные реальным кодом. Это не тот процесс, который я хочу пережить еще раз.
Когда вы тестируете такого рода вещи, вы пытаетесь придумать несколько типичных случаев, запрограммировать их в свои тесты и надеяться, что вы охватили основные области. Что было бы гораздо приятнее, если бы вы могли вставить целую массу различных данных в тестируемую систему и убедиться, что результаты выглядят вменяемыми.
Примером драйвера Java является то, что у нас были тесты, которые проверяли разбор URI — вы можете инициализировать настройки MongoDB, просто используя строку, содержащую URI.
Старые тесты выглядели так:
@Test() public void testSingleServer() { MongoClientURI u = new MongoClientURI("mongodb://db.example.com"); assertEquals(1, u.getHosts().size()); assertEquals("db.example.com", u.getHosts().get(0)); assertNull(u.getDatabase()); assertNull(u.getCollection()); assertNull( u.getUsername()); assertEquals(null, u.getPassword()); } @Test() public void testWithDatabase() { MongoClientURI u = new MongoClientURI("mongodb://foo/bar"); assertEquals(1, u.getHosts().size()); assertEquals("foo", u.getHosts().get(0)); assertEquals("bar", u.getDatabase()); assertEquals(null, u.getCollection()); assertEquals(null, u.getUsername()); assertEquals(null, u.getPassword()); } @Test() public void testWithCollection() { MongoClientURI u = new MongoClientURI("mongodb://localhost/test.my.coll"); assertEquals("test", u.getDatabase()); assertEquals("my.coll", u.getCollection()); } @Test() public void testBasic2() { MongoClientURI u = new MongoClientURI("mongodb://foo/bar.goo"); assertEquals(1, u.getHosts().size()); assertEquals("foo", u.getHosts().get(0)); assertEquals("bar", u.getDatabase()); assertEquals("goo", u.getCollection()); }
(См.
MongoClientURITest )
Используя тестирование на основе данных Спока, мы изменили это на:
@Unroll def 'should parse #uri into correct components'() { expect: uri.getHosts().size() == num; uri.getHosts() == hosts; uri.getDatabase() == database; uri.getCollection() == collection; uri.getUsername() == username; uri.getPassword() == password; where: uri | num | hosts | database | collection | username | password new MongoClientURI('mongodb://db.example.com') | 1 | ['db.example.com'] | null | null | null | null new MongoClientURI('mongodb://foo/bar') | 1 | ['foo'] | 'bar' | null | null | null new MongoClientURI('mongodb://localhost/' + 'test.my.coll') | 1 | ['localhost'] | 'test' | 'my.coll' | null | null new MongoClientURI('mongodb://foo/bar.goo') | 1 | ['foo'] | 'bar' | 'goo' | null | null new MongoClientURI('mongodb://user:pass@' + 'host/bar') | 1 | ['host'] | 'bar' | null | 'user' | 'pass' as char[] new MongoClientURI('mongodb://user:pass@' + 'host:27011/bar') | 1 | ['host:27012'] | 'bar' | null | 'user' | 'pass' as char[] new MongoClientURI('mongodb://user:pass@' + 'host:7,' + 'host2:8,' + 'host3:9/bar') | 3 | ['host:7', 'host2:8', 'host3:9'] | 'bar' | null | 'user' | 'pass' as char[] }
(См.
MongoClientURISpecification )
Вместо того, чтобы иметь отдельный тест для каждого типа URL, который необходимо проанализировать, у вас есть один тест, и каждая строка в разделе
where: представляет собой новую комбинацию входного URL-адреса и ожидаемых выходных данных. Каждая из этих строк раньше была тестом. Фактически, некоторые из них, вероятно, не были тестами, поскольку уродство и издержки добавления еще одного теста копирования-вставки казались излишними. Но здесь, в Споке, это просто случай добавления еще одной строки с новым входом и набором выходов.
Основное преимущество для меня заключается в том, что добавить еще один тест «а что если?» Очень просто. что происходит с разработчиком. Вам не нужно иметь еще один метод тестирования, который кто-то другой задастся вопросом: «Какого черта мы это тестируем?». Вы просто добавляете еще одну строку, которая документирует другой набор ожидаемых результатов с учетом нового ввода.
Это легко, аккуратно, лаконично.
Одним из основных преимуществ этого для нашей команды является то, что мы больше не спорим о том, тестирует ли один тест слишком много. В прошлом у нас были такие тесты:
@Test public void testGetLastErrorCommand() { assertEquals(new BasicDBObject("getlasterror", 1), WriteConcern.UNACKNOWLEDGED.getCommand()); assertEquals(new BasicDBObject("getlasterror", 1), WriteConcern.ACKNOWLEDGED.getCommand()); assertEquals(new BasicDBObject("getlasterror", 1).append("w", 2), WriteConcern.REPLICA_ACKNOWLEDGED.getCommand()); assertEquals(new BasicDBObject("getlasterror", 1).append("j", true), WriteConcern.JOURNALED.getCommand()); assertEquals(new BasicDBObject("getlasterror", 1).append("fsync", true), WriteConcern.FSYNCED.getCommand()); assertEquals(new BasicDBObject("getlasterror", 1).append("w", "majority"), new WriteConcern("majority").getCommand()); assertEquals(new BasicDBObject("getlasterror", 1).append("wtimeout", 100), new WriteConcern(1, 100).getCommand()); }
И я могу понять, почему у нас есть все эти утверждения в одном и том же тесте, потому что технически это все одна и та же концепция — убедитесь, что каждый тип WriteConcern создает правильный документ команды. Я считаю, что это должен быть один тест на строку — потому что каждая строка в тесте тестирует разные входные и выходные данные, и я хотел бы задокументировать это в имени теста («в записи fsync должен быть флаг fsync в команде getLastError», « Записываемая запись о записи должна установить для флага j значение true в команде getLastError и т. д.). Также не забывайте, что в JUnit, если первое утверждение не удается, остальная часть теста не запускается. Поэтому вы понятия не имеете, является ли это сбой, который затрагивает все проблемы записи, или только первый. Вы теряете покрытие, предоставленное последующими утверждениями.
Но аргумент против моей точки зрения состоит в том, что у нас будет семь разных однострочных тестов. Какая трата пространства.
Вы могли бы несколько дней спорить о том, как лучше всего это сделать, или что этот тест является признаком какого-то другого запаха, который необходимо устранить. Но если вы работаете в реальном мире, и ваша цель состоит в том, чтобы как улучшить охват тестированием, так и улучшить сами тесты, эти аргументы мешают прогрессу. Хорошая особенность Спока в том, что вы можете взять эти тесты, которые слишком много тестируют, и превратить их во что-то более красивое:
@Unroll def '#wc should return getlasterror document #commandDocument'() { expect: wc.asDocument() == commandDocument; where: wc | commandDocument WriteConcern.UNACKNOWLEDGED | ['getlasterror': 0] WriteConcern.ACKNOWLEDGED | ['getlasterror': 1] WriteConcern.REPLICA_ACKNOWLEDGED | ['getlasterror': 1, 'w': 2] WriteConcern.JOURNALED | ['getlasterror': 1, 'j': true] WriteConcern.FSYNCED | ['getlasterror': 1, 'fsync': true] new WriteConcern('majority') | ['getlasterror': 1, 'w': 'majority'] new WriteConcern(1, 100) | ['getlasterror': 1, 'wtimeout': 100] }
Вы можете подумать, в чем преимущество JUnit? Разве это не то же самое, но Groovier? Но есть одно важное отличие — все строки в разделе
где: запускаются, независимо от того, пройден ли тест до того, как он пройден, или не пройден. В основном это семь разных тестов, но они занимают то же место, что и один.
Это замечательно, но если только одна из этих строк дает сбой, как вы узнаете, какая это была, если все семь тестов маскируются под один? Вот тут и появляется
замечательная аннотация @Unroll. Она сообщает о прохождении или сбое каждой строки, как если бы это был отдельный тест. По умолчанию, когда вы запускаете развернутый тест, он будет выглядеть примерно так:
Но в приведенном выше тесте мы добавили несколько магических ключевых слов в имя теста:
« #wc должен вернуть getlasterror document #commandDocument » — обратите внимание, что эти значения с
# впереди — это те же заголовки из
раздела where : . Они будут заменены значением, используемым в текущем тесте:
Да, это может быть немного глотком, если
toString здоров, но он дает вам представление о том, что тестировалось, и лучше, если входные данные имеют хорошие сжатые строковые значения:
Это, в сочетании с удивительным утверждением власти Спока,
упрощает понимание того, что пошло не так, когда один из этих тестов не пройден. Давайте возьмем пример (каким-то образом) неверного хоста, возвращаемого для одного из входных URI:
Тестирование на основе данных может привести к переоценке простых вещей, но стоит добавить еще «что если?» так низко — просто еще одна строка — и дополнительная безопасность, которую вы получаете, пробуя другой ввод, довольно приятна. Мы использовали их для анализаторов и простых генераторов, где вы хотите добавить несколько входных данных в один метод и посмотреть, что вы получите.
Я полностью продан этой функции, особенно для нашего типа приложения (драйвер Java много делает, принимая вещи в одной форме и превращая их во что-то другое). На всякий случай, если вам нужен последний пример, вот последний.
По старому:
@Test public void shouldGenerateIndexNameForSimpleKey() { final Index index = new Index("x"); assertEquals("x_1", index.getName()); } @Test public void shouldGenerateIndexNameForKeyOrderedAscending() { final Index index = new Index("x", OrderBy.ASC); assertEquals("x_1", index.getName()); } @Test public void shouldGenerateIndexNameForKeyOrderedDescending() { final Index index = new Index("x", OrderBy.DESC); assertEquals("x_-1", index.getName()); } @Test public void shouldGenerateGeoIndexName() { final Index index = new Index(new Index.GeoKey("x")); assertEquals("x_2d", index.getName()); } @Test public void shouldCompoundIndexName() { final Index index = new Index(new Index.OrderedKey("x", OrderBy.ASC), new Index.OrderedKey("y", OrderBy.ASC), new Index.OrderedKey("a", OrderBy.ASC)); assertEquals("x_1_y_1_a_1", index.getName()); } @Test public void shouldGenerateGeoAndSortedCompoundIndexName() { final Index index = new Index(new Index.GeoKey("x"), new Index.OrderedKey("y", OrderBy.DESC)); assertEquals("x_2d_y_-1", index.getName()); }
… и в Споке:
@Unroll def 'should generate index name #indexName for #index'() { expect: index.getName() == indexName; where: index | indexName new Index('x') | 'x_1' new Index('x', OrderBy.ASC) | 'x_1' new Index('x', OrderBy.DESC) | 'x_-1' new Index(new Index.GeoKey('x')) | 'x_2d' new Index(new Index.OrderedKey('x', OrderBy.ASC), new Index.OrderedKey('y', OrderBy.ASC), new Index.OrderedKey('a', OrderBy.ASC)) | 'x_1_y_1_a_1' new Index(new Index.GeoKey('x'), new Index.OrderedKey('y', OrderBy.DESC)) | 'x_2d_y_-1' }