В то время как модульное тестирование всегда предпочтительнее, интеграционные тесты являются хорошим и необходимым дополнением либо для выполнения сквозных тестов, либо для тестов с участием сторонних разработчиков. Базы данных являются таким кандидатом, когда интеграция может иметь смысл: обычно мы инкапсулируем постоянство с каким-то уровнем обслуживания репозитория, который мы можем смоделировать в тестах, работающих с репозиторием. Но когда дело доходит до тестирования самого репозитория, интеграционные тесты весьма полезны. Интеграционные тесты Spring позволяют тестировать функциональность по отношению к работающему приложению Spring и тем самым позволяют тестировать по работающему экземпляру базы данных Но, как и в модульных тестах, вы должны выполнить правильную настройку тестовых данных и впоследствии очистить базу данных. Вот о чем эта статья: правильная настройка и очистка базы данных в интеграционных тестах Spring с MongoDB.
Билеты, пожалуйста
Давайте сначала представим наш слой репозитория для тестирования. Скажем, мы хотим создать простую систему тикетов, которая размещается в MongoDB. Тикет довольно прост: у него есть описание, (нетехнический) тикет и идентификатор объекта MongoDB.
@Document
public class Ticket {
@Id
private ObjectId id;
@Indexed(unique = true)
private String ticketId;
private String description;
Примечание: использование технического идентификатора (MongoDB) в качестве функционального идентификатора редко является хорошей идеей, его всегда следует хранить отдельно.
Мы хотим обеспечить уникальность идентификаторов билетов, добавив уникальный индекс, который мы можем сделать в Spring, просто добавив @Indexed
аннотацию.
Это TicketRepository
также довольно просто. В дополнение к стандартным методам CRUD он определяет метод получения заявки по его идентификатору:
public interface TicketRepository extends MongoRepository {
Ticket findByTicketId(final String ticketId);
}
Вы можете утверждать, что пока мы не предоставляем настраиваемую логику персистентности, нет необходимости писать тесты для стандартной функциональности Spring. Но здесь мы реализуем бизнес-логику, используя функции базы данных — в этом случае уникальность идентификаторов заявок — поэтому, по крайней мере, стоит написать тест. Давайте начнем с простого теста, который просто создает два тикета и просматривает их, используя наш метод поиска:
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class TicketRepositoryIT {
@Autowired
private TicketRepository repository;
@Autowired
private MongoTemplate mongoTemplate;
@Test
public void testSaveAndFindTicket() throws Exception {
Ticket ticket1 = new Ticket("1", "blabla");
repository.save(ticket1);
Ticket ticket2 = new Ticket("2", "hihi");
repository.save(ticket2);
assertEquals(ticket1, repository.findByTicketId("1"));
assertEquals(ticket2, repository.findByTicketId("2"));
assertNull(repository.findByTicketId("3"));
}
}
Хорошо, пусть работает. Зеленый, так какой у тебя смысл? Позвольте этому бежать снова. Э, красный? Какого черта?!? StackTrace — ваш друг:
org.springframework.dao.DuplicateKeyException:
{ "serverUsed" : "localhost:27017" , "ok" : 1 , "n" : 0 ,
"err" : "E11000 duplicate key error index: test.ticket.$ticketId dup key: { : \"1\" }" , "code" : 11000};
...
А DuplicateKeyException
, а? Имеет смысл: наши тесты всегда пытаются создать заявки с идентификаторами «1» и «2». С нашей первой попытки база данных была пуста. Но во втором случае они уже существовали, и MongoDB жалуется на то, что наше уникальное ограничение индекса нарушено.
Очистите свой тестовый мусор
Таким образом, мы должны хорошо очистить нашу тестовую грязь, до и после теста. Не так сложно. Поскольку тестирование всегда должно выполняться на выделенной базе данных для целей тестирования, мы можем просто удалить коллекцию:
public class TicketRepositoryIT {
...
@Before
public void setup() throws Exception {
mongoTemplate.dropCollection(Ticket.class);
}
@After
public void tearDown() throws Exception {
mongoTemplate.dropCollection(Ticket.class);
}
...
Теперь запустите тест снова. И снова. Ах, зеленый 🙂
Весенние и MongoDB индексы
Поскольку мы используем индекс для реализации бизнес-логики уникальных идентификаторов билетов, мы должны написать тест для обеспечения этой функциональности. Это просто, мы просто создаем два билета с одинаковым идентификатором и ожидаемDuplicateKeyException)
@Test(expected = DuplicateKeyException.class)
public void testSaveNewTicketWithExistingTicketId() throws Exception {
repository.save(new Ticket("1", "blabla"));
repository.save(new Ticket("1", "hihi"));
}
И если мы запустим тест, мы получим наш ожидаемый … э, красный?!? Что произошло? Что ж, Spring прекрасно создает все индексы в коллекции, проверяя наш класс сущностей при запуске. Но когда мы отбрасываем коллекцию, упадут и индексы. Имеет смысл. Но в настоящее время Spring не воссоздает индексы при следующей вставке, см. Эту проблему для получения дополнительной информации об обсуждении. Таким образом, в то же время, мы должны сделать это на себя. Получив знания из класса, MongoPersistentEntityIndexCreator
мы можем легко написать код для воссоздания индекса для данного класса сущности:
protected void createIndecesFor(final Class<?> type) {
final MongoMappingContext mappingContext =
(MongoMappingContext) getMongoTemplate().getConverter().getMappingContext();
final MongoPersistentEntityIndexResolver indexResolver =
new MongoPersistentEntityIndexResolver(mappingContext);
for (final IndexDefinitionHolder indexToCreate : indexResolver.resolveIndexForClass(type)) {
createIndex(indexToCreate);
}
}
private void createIndex(final IndexDefinitionHolder indexDefinition) {
getMongoTemplate().getDb().getCollection(indexDefinition.getCollection())
.createIndex(indexDefinition.getIndexKeys(), indexDefinition.getIndexOptions());
}
Все, что нам нужно сделать сейчас, это воссоздать индексы после удаления коллекции в настройках:
@Before
public void setup() throws Exception {
mongoTemplate.dropCollection(Ticket.class);
createIndecesFor(Ticket.class);
}
@After
public void tearDown() throws Exception {
mongoTemplate.dropCollection(Ticket.class);
}
Если мы теперь перезапустим тесты … ах, зеленый снова 😀
Сделай это правилом
Поскольку мы не хотим копировать этот код в любой, мы можем извлечь его в базовый класс, но … это действительно отстой. В Junit 4 введены правила для выделения такого вспомогательного кода. ExternalResource правило является довольно идеальной базой для правил, которые должны выполняться до и после каждого испытания, так что давайте делать это таким образом:
public class MongoCleanupRule extends ExternalResource {
...
@Override
protected void before() throws Throwable {
dropCollections();
createIndeces();
}
@Override
protected void after() {
dropCollections();
}
Поскольку мы хотим, чтобы наше правило можно было настраивать с помощью одно-n коллекций, мы передадим классы коллекций в конструктор правил:
private final Class<?>[] collectionClasses;
public MongoCleanupRule(final Class<?>... collectionClasses) {
Assert.notNull(collectionClasses, "parameter 'collectionClasses' must not be null");
Assert.noNullElements(collectionClasses,
"array 'collectionClasses' must not contain null elements");
this.collectionClasses = collectionClasses;
}
@Override
protected void before() throws Throwable {
dropCollections();
createIndeces();
}
@Override
protected void after() {
dropCollections();
}
protected Class<?>[] getMongoCollectionClasses() {
return collectionClasses;
}
protected void dropCollections() {
for (final Class<?> type : getMongoCollectionClasses()) {
getMongoTemplate().dropCollection(type);
}
}
protected void createIndeces() {
for (final Class<?> type : getMongoCollectionClasses()) {
createIndecesFor(type);
}
}
Это просто: мы просто перебираем данные коллекции монго, отбрасываем их и воссоздаем индексы для каждой из них. Но подождите, откуда берется требуемый MongoTemplate
?!? Ну, мы могли бы передать это в конструкторе вместе с классами коллекции. Но если вы помните наш интеграционный тест, шаблон внедряется с помощью Spring @Autowired
, что довольно удобно. Но у нас нет определенного времени, когда шаблон вводится, поэтому мы должны быть здесь ленивыми. Вместо передачи шаблона в правило, правило извлекает его из тестового класса с помощью отражения. Тестовый класс должен предоставлять шаблон либо в переменной-члене, либо в методе получения, чьи имена можно настраивать. Мы определяем имена по умолчанию, чтобы быть mongoTemplate
и getMongoTemplate
.
public class MongoCleanupRule extends ExternalResource {
private final Object testClassInstance;
private final Class<?>[] collectionClasses;
private final String fieldName;
private final String getterName;
public MongoCleanupRule(final Object testClassInstance, final Class<?>... collectionClasses) {
this(testClassInstance, "mongoTemplate", "getMongoTemplate", collectionClasses);
}
public MongoCleanupRule(final Object testClassInstance, final String fieldOrGetterName,
final Class<?>... collectionClasses) {
this(testClassInstance, fieldOrGetterName, fieldOrGetterName, collectionClasses);
}
protected MongoCleanupRule(final Object testClassInstance, final String fieldName,
final String getterName, final Class<?>... collectionClasses) {
Assert.notNull(testClassInstance, "parameter 'testClassInstance' must not be null");
Assert.notNull(fieldName, "parameter 'fieldName' must not be null");
Assert.notNull(getterName, "parameter 'getterName' must not be null");
Assert.notNull(collectionClasses, "parameter 'collectionClasses' must not be null");
Assert.noNullElements(collectionClasses,
"array 'collectionClasses' must not contain null elements");
this.fieldName = fieldName;
this.getterName = getterName;
this.testClassInstance = testClassInstance;
this.collectionClasses = collectionClasses;
}
...
protected MongoTemplate getMongoTemplate() {
try {
Object value = ReflectionTestUtils.getField(testClassInstance, fieldName);
if (value instanceof MongoTemplate) {
return (MongoTemplate) value;
}
value = ReflectionTestUtils.invokeGetterMethod(testClassInstance, getterName);
if (value instanceof MongoTemplate) {
return (MongoTemplate) value;
}
} catch (final IllegalArgumentException e) {
// throw exception with dedicated message at the end
}
throw new IllegalArgumentException(
String.format("%s expects either field '%s' or method '%s' in order to access the required MongoTemmplate",
this.getClass().getSimpleName(), fieldName, getterName));
}
}
Это сводит наш интеграционный тест к следующим строкам:
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class TicketRepositoryIT {
@Autowired
private TicketRepository repository;
@Autowired
private MongoTemplate mongoTemplate;
@Rule
public final MongoCleanupRule cleanupRule = new MongoCleanupRule(this, Ticket.class);
@Test
public void testSaveAndFindTicket() throws Exception {
Ticket ticket1 = new Ticket("1", "blabla");
repository.save(ticket1);
Ticket ticket2 = new Ticket("2", "hihi");
repository.save(ticket2);
assertEquals(ticket1, repository.findByTicketId("1"));
assertEquals(ticket2, repository.findByTicketId("2"));
assertNull(repository.findByTicketId("3"));
}
@Test(expected = DuplicateKeyException.class)
public void testSaveNewTicketWithExistingTicketId() throws Exception {
repository.save(new Ticket("1", "blabla"));
repository.save(new Ticket("1", "hihi"));
}
Так как наша переменная-член, содержащая имя, MongoTemplate
имеет имя по умолчанию, определенное в правиле, в правиле настраивать особо нечего. Просто предоставьте сам экземпляр тестового класса (чтобы мы могли получить доступ к шаблону с помощью отражения) и класс коллекции. Это все, что нам нужно, достаточно, чтобы все было сделано.
Вот и все. Вы можете найти этот тестовый проект и правило на GitHub .
Помните, правило номер один: правил нет!
Мик Джаггер