Wiremongo is an alternative implementation of the Vert.x MongoClient
interface that can be used to mock mongo calls. It is most useful for unit testing mongo database code and is more lightweight than running a mongodb instance.
To use Vert.x Wiremongo, add the following dependency:
-
Maven (in your
pom.xml
):
<dependency>
<groupId>com.noenv</groupId>
<artifactId>vertx-wiremongo</artifactId>
<version>4.5.10</version>
</dependency>
-
Gradle (in your
build.gradle
file):
compile 'com.noenv:vertx-wiremongo:4.5.10'
Imagine you have a mongodb to keep track of your fruit. In your application you have a class that uses io.vertx.ext.mongo.MongoClient
for the database operations:
public class FruitDatabase {
private final MongoClient mongo;
public FruitDatabase(MongoClient mongo) {
this.mongo = mongo;
}
public Future<Void> addApple(int mass, Instant expiration) {
return insertFruit("apple", mass, expiration).mapEmpty();
}
public Future<Void> addBanana(int mass, Instant expiration) {
return insertFruit("banana", mass, expiration).mapEmpty();
}
private Future<String> insertFruit(String type, int mass, Instant expiration) {
var p = Promise.<String>promise();
mongo.insert("fruits", new JsonObject()
.put("type", type)
.put("mass", mass)
.put("expiration", new JsonObject().put("$date", expiration)), p);
return p.future();
}
public Future<Long> countFruitByType(String type) { }
public Future<Long> removeExpiredFruit() { }
// etc.
}
To test this class, simply create an instance of WireMongo
, set up some mocking using its fluent methods, and use it as the MongoClient
implementation in your tests:
@RunWith(VertxUnitRunner.class)
public class FruitDatabaseTest {
private WireMongo wiremongo;
private FruitDatabase db;
@Before
public void setUp() {
wiremongo = new WireMongo();
db = new FruitDatabase(wiremongo.getClient());
}
@Test
public void testAddApple(TestContext ctx) {
var expiration = Instant.now().plus(5, ChronoUnit.DAYS);
wiremongo.insert()
.inCollection("fruits")
.withDocument(new JsonObject()
.put("type", "apple")
.put("mass", 161)
.put("expiration", new JsonObject().put("$date", expiration)))
.returnsObjectId();
db.addApple(161, expiration)
.onComplete(ctx.asyncAssertSuccess());
}
}
You can also test error cases:
@Test
public void testInsertError(TestContext ctx) {
wiremongo.insert()
.returnsDuplicateKeyError();
db.addApple(123, Instant.now().plus(3, ChronoUnit.DAYS))
.onComplete(ctx.asyncAssertFailure());
}
You can find the complete source code of these examples in src/test/java/com/noenv/wiremongo/examples
.
When setting up a mock, all the matching criteria can also be defined by custom matchers. The following mock would match all update
commands that try to update the expiration of bananas:
wiremongo.updateCollection()
.inCollection("fruits")
.withQuery(q -> q.getString("type").equals("banana"))
.withUpdate(u -> u.getJsonObject("$set").containsKey("expiration"))
.returnsTimeoutException();
If you don’t specify a custom matcher, Objects.equals
is used by default. Since a lot of interaction with mongo happens using JsonObject
and JsonArray
, there is also a JsonMatcher
that can be used like this:
import static com.noenv.wiremongo.matching.JsonMatcher.equalToJson;
wiremongo.insert()
.inCollection("fruits")
.withDocument(equalToJson(new JsonObject().put("type", "banana"), /*ignoreExtraElements*/ true))
.returns("2ad7533f");
The above example matches all commands that try to insert bananas into fruits. Note that the json matcher supports a flag ignoreExtraElements
that allows these insert documents to be matched even if they contain additional fields (e.g. mass & expiration).
If several mocks are set up for the same command and matching criteria, WireMongo will use the mock with the highest priority. The default behaviour is to give mocks an increasing priority as they are added so the most recently added always has the highest priority:
wiremongo.count().inCollection("fruits").returns(21L);
wiremongo.count().inCollection("fruits").returns(42L);
// a call to mongo.count("fruits") will return 42
However, priorities can be user-defined:
wiremongo.count().inCollection("fruits").priority(13).returns(21L);
wiremongo.count().inCollection("fruits").priority(11).returns(42L);
// a call to mongo.count("fruits") will return 21
Stubs are the response part of the mock, i.e. they define how the mock responds to commands that match. The most low-level stubs are custom stubs:
wiremongo.findOne()
.inCollection("fruits")
.stub(c -> new JsonObject()
.put("type", "apple")
.put("mass", 123)
.put("expiration", new JsonObject().put("$date", Instant.now())));
Sometimes it may be useful to assert that the application actually invokes the expected mongo command:
@Test
public void testInsert(TestContext ctx) {
Async async = ctx.async();
wiremongo.insert()
.stub(c -> {
async.countDown();
return "37bd238fa";
});
application.addApple(); // adding an apple should trigger an insert command
}
The returns("1234")
method is just a more convenient way for stub(c → "1234")
.
Stubs can also throw exceptions:
wiremongo.count()
.stub(c -> { throw new MongoTimeoutException("intentional"); });
For the most common errors, wiremongo contains helper methods that match the types and messages of an actual mongo instance (returnsDuplicateKeyError
, returnsTimeoutException
, returnsConnectionException
).
Multiple stubs can be configured for a mock. The stubs are used once each in the order they are added, the last one is used forever. Consider the following mock:
wiremongo.insert()
.returns("37bd238fa")
.returns("73ab6cf21")
.returnsDuplicateKeyError();
The above code will return ids for the first two and a duplicate key error for every subsequent insert command.
If you want to add a mapping that matches all mongo commands, you can use matchAll
:
wiremongo.matchAll()
.stub(c -> {
ctx.assertTrue(c.method().equals("replaceDocuments") || c.method().equals("insert"));
log("mongo received command: " + c);
return 42;
});
Match All is not supported for file mappings however.
Mocks can also be defined in json files. You can ask wiremongo to read files from a directory like this:
@Before
public void setUp(TestContext ctx) {
wiremongo = new WireMongo(vertx);
wiremongo.readFileMappings("test/resources/wiremongo-files")
.onComplete(ctx.asyncAssertSuccess());
}
The wiremongo json files look like this:
{
"method": "insert",
"collection": {
"equalTo": "fruits"
},
"document": {
"equalToJson": {
"type": "banana",
"mass": 7533
},
"ignoreExtraElements": true
},
"response": "388adf7ab"
}
The details depend on the command that is mocked. To get started, it is easiest to just look at the json file for the command you want to mock in the src/test/resources/wiremongo-mocks
folder of this project.
Very often it is not only important to have mocks for a database ready, but also to make sure those are used or even used properly. Verifications let you check if a call to database is made at all, or made for specific times or even never made.
Basic setup for verification is to have a Verifier
and make sure it is reset before each test and all its verifications are asserted after each test. For example using JUnit:
public class SomeTestClass {
private Verifier verifier;
@Before
public void setUpTest() {
verifier = new Verifier();
}
@After
public void tearDownTest() {
verifier.assertAllSucceeded();
}
// your tests go here
}
Then each mock can define a verification when it is set up. For example:
public class SomeTestClass {
// ...
@Test
public void verify_RunExactlyOnce_shall_fail_ifRunTwice(TestContext ctx) {
// ...
mock
.findOneAndUpdate()
.inCollection("some-collection")
.verify(
verifier
.checkIf("find one and update in some-collection")
.isRunExactlyOnce()
)
.returns(null);
// ...
}
}
The requirements defined will be checked for in the @After
annotated method.