Статьи

Весенние интеграционные тесты с MongoDB Rulez

В то время как модульное тестирование всегда предпочтительнее, интеграционные тесты являются хорошим и необходимым дополнением либо для выполнения сквозных тестов, либо для тестов с участием сторонних разработчиков. Базы данных являются таким кандидатом, когда интеграция может иметь смысл: обычно мы инкапсулируем постоянство с каким-то уровнем обслуживания репозитория, который мы можем смоделировать в тестах, работающих с репозиторием. Но когда дело доходит до тестирования самого репозитория, интеграционные тесты весьма полезны. Интеграционные тесты 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 .

Помните, правило номер один: правил нет!
Мик Джаггер