Business Logic Data Preparation
Business logic testing often involves creating data input and validating logic output. I want to show you how you can simplify complex domain data creation using WebTau API.Throughout this article we will be building a Game Store server and its various business rules.Let's start with implementation and testing of games checkout discount logic.Business department came up with the idea that we should encourage people to buy different game genres. So discount depends on:how many distinct type of games you buy each distinct game type will yield 10% discount, as long as games cost more than $15 no more than three distinct games types will be counted As an example, given the following checkout cart, a user will get a 20% discount: id type price g1 RPG 10 g2 RPG 50 g3 Action 70 g4 Puzzle 10 Let's create a discount calculator to implement the business rule: public class DiscountCalculator { public int calculateDiscountPercentage(List<Game> games) { long numberOfDistinctTypes = games.stream() .filter(game -> game.getPriceUsd().compareTo(BigDecimal.valueOf(15)) > 0) // price filter .map(Game::getType) .distinct() // only distinct game types .count(); long limitedDistinctTypes = Math.min(3, numberOfDistinctTypes); // no more than three return (int) (limitedDistinctTypes * 10); } } To test that code we will need to create a sufficient number of Game instances to make sure we cover:Count only one game per type Count only games with high enough price Should count no more than three distinct types Creating many Game instances is a lot of boilerplate: Game game1 = new Game(); game1.setId("id1"); game1.setType("RPG"); game1.setPriceUsd(BigDecimal.valueOf(12)); Game game2 = new Game(); game1.setId("id2"); game1.setType("RPG"); game1.setPriceUsd(BigDecimal.valueOf(28)); Game game3 = new Game(); game1.setId("id3"); game1.setType("RPG"); game1.setPriceUsd(BigDecimal.valueOf(70)); // .... List<Game> checkoutGames = Arrays.asList(game1, game2, game3); When we deal with boilerplate multiple things can happen:copy pasting and not updating values not creating enough instances To streamline the process of data creation, WebTau provides table API: public class DiscountCalculatorTest { @Test public void discountBasedUniqueGenresAbove15DollarsLimitedBy3() { List<Game> checkoutGames = createGames(table("id" , "type", "price", // WebTau table API to create domain data ___________________________, cell.guid, "RPG", 12, // cell.guid generated unique id for each row cell.guid, "RPG", 28, cell.guid, "RPG", 70, // three games with the same Type to make sure we pick only one distinct even though 2 match price criteria cell.guid, "FPS", 30, cell.guid, "FPS", 10, cell.guid, "RTS", 60, cell.guid, "Sport", 30)); // extra game type to make sure we stop at 3 int discountPercentage = new DiscountCalculator().calculateDiscountPercentage(checkoutGames); actual(discountPercentage, "discount").should(equal(30)); // validate the result } } Let's take a look at implementation of createGames : public static List<Game> createGames(TableData table) { // table data is like list of map return table.rowsStream() // you can stream rows .map(GamesData::createGame) .collect(Collectors.toList()); } public static Game createGame(Record row) { Game game = new Game(); game.setId(row.get("id")); game.setType(row.get("type")); game.setTitle(row.get("title", "")); // default values when not provided game.setPriceUsd(BigDecimal.valueOf((int) row.get("price", 0))); return game; // and create domain data you need } By using table and a domain specific methods like createGames you make your tests easier to write, read and maintain.
Business Logic Data Validation
We just simplified our data creation process. Now let's see how we can simplify data validation one.This time we will test a recommendation engine. One of its role is to provide a new game to play based on the games you already played. For now the engine will recommend the least played game in the least played type of games.Here is the test: public class GameRecommendationEngineTest { @Test public void shouldRecommendNextGameWhereAccumulatedPerTypeIsLowest() { GamesLibrary library = createLibrary(table("id", "type", "title", "hoursPlayed", // create game library with play stats _________________________________________, "g1", "RPG", "Divinity 2", 130, "g2", "Sport", "NBA 2023", 1, "g3", "JRPG", "Persona 5", 300)); GameRecommendationEngine engine = new GameRecommendationEngine(library); actual(engine.recommendNextGame(), "nextGame").should(equal(map( // assert on Game bean using map "id", "g2", "type", "Sport", "title", contain("2023"), // using a matcher for a field "stats", map("hoursPlayed", 1)))); } } Our current implementation has a bug, and uses incorrect type for the returned game. When mismatch occurs, WebTau output provides all the information you need to understand the issue and highlights important parts of the output: X failed expecting nextGame to equal {"id": "g2", "type": "Sport", "title": <contain "2023">, "stats": {"hoursPlayed": 1}}: nextGame.type: actual: "RPG" <java.lang.String> expected: "Sport" <java.lang.String> ^ (39ms) { "id": "g2", "priceUsd": null, "stats": {"gameId": "g2", "hoursPlayed": 1}, "title": "NBA 2023", "type": **"RPG"** } WebTau did good with an object validation with a nested object. Let's take a look at how it fares with a list of objects.Games Library class has a method to find top N games. It returns a list of games. Let's test it. public class GamesLibraryTest { @Test public void shouldFindTopTwoGames() { GamesLibrary library = createLibrary(table("id", "type", "title", "hoursPlayed", "price", _________________________________________________________, "g1", "RPG", "Divinity 2", 130, 30, "g2", "Sport", "NBA 2023", 1, 40, "g3", "JRPG", "Persona 5", 300, 20, "g4", "PainAction", "Elden Ring", 200, 60)); trace("all games", propertiesTable(library.getGames())); // tracing all games when need to debug List<Game> topTwoGames = library.findTopNGames(2); // find top two games as list of game TableData expected = table( "*id", "type", "title", "priceUsd" , "stats", // expectation as table with key column <id> _____________________________________________________________________________, "g3", "JRPG", "Persona 5", lessThan(60), map("hoursPlayed", 300), // using a matcher inside a table cell "g4", "PainAction", "Elden Ring", 60 , map("hoursPlayed", 200)); actual(topTwoGames, "top2").should(equal(expected)); // compare with list of java beans order agnostic } } [tracing] all games id │ priceUsd │ stats │ title │ type "g1" │ null │ {"gameId": "g1", "hoursPlayed": 130} │ "Divinity 2" │ "RPG" "g2" │ null │ {"gameId": "g2", "hoursPlayed": 1} │ "NBA 2023" │ "Sport" "g3" │ null │ {"gameId": "g3", "hoursPlayed": 300} │ "Persona 5" │ "JRPG" "g4" │ null │ {"gameId": "g4", "hoursPlayed": 200} │ "Elden Ring" │ "PainAction" X failed expecting top2 to equal *id │ type │ title │ priceUsd │ stats "g3" │ "JRPG" │ "Persona 5" │ <less than 60> │ {"hoursPlayed": 300} "g4" │ "PainAction" │ "Elden Ring" │ 60 │ {"hoursPlayed": 200}: top2[0].priceUsd: actual: null expected: <less than 60> <org.testingisdocumenting.webtau.expectation.equality.LessThanMatcher> top2[1].priceUsd: actual: null expected: 60 <java.lang.Integer> (10ms) id │ priceUsd │ stats │ title │ type "g3" │ **null** │ {"gameId": "g3", "hoursPlayed": 300} │ "Persona 5" │ "JRPG" "g4" │ **null** │ {"gameId": "g4", "hoursPlayed": 200} │ "Elden Ring" │ "PainAction" Using trace method is optional and can be helpful when dealing with complex data to see what's going on. When failure happens, WebTau displays actual complex data and highlights values within that you need to take a look at.We did validation using TableData . Let's take a look at how a property by property validation would look like. List<Game> topTwoGames = library.findTopNGames(2); Game gameOne = topTwoGames.get(0); assertEquals("g3", gameOne.getId()); assertEquals("JRPG", gameOne.getType()); assertEquals("Persona 5", gameOne.getTitle()); assertEquals(BigDecimal.valueOf(20), gameOne.getPriceUsd()); assertEquals(300, gameOne.getStats().getHoursPlayed()); Game gameTwo = topTwoGames.get(1); assertEquals("g3", gameOne.getId()); assertEquals("PainAction", gameOne.getType()); assertEquals("Elden Ring", gameOne.getTitle()); assertEquals(BigDecimal.valueOf(60), gameOne.getPriceUsd()); assertEquals(200, gameOne.getStats().getHoursPlayed()); To contrast it with table approach: TableData expected = table( "*id", "type", "title", "priceUsd" , "stats", // expectation as table with key column <id> _____________________________________________________________________________, "g3", "JRPG", "Persona 5", lessThan(60), map("hoursPlayed", 300), // using a matcher inside a table cell "g4", "PainAction", "Elden Ring", 60 , map("hoursPlayed", 200)); When dealing with validation boilerplate code you can face even worse problems than with data preparation:ignore certain fields validation copy and paste wrong values and they match incorrect logic
We tested some business logic and now lets move on to REST API testing. Game server exposes /api/game end-point to list available games. To test it, we will manually insert records into GAME database table and then use http.get request to validate a piece of data. public class GameRestTest { private final DatabaseTable GAME = db.table("GAME"); // declare GAME table @Test public void fetchListOfGames() { GAME.clear(); // remove all entries from the DB table GAME.insert(table("id", "title", "type", "priceUsd", // insert two rows with auto conversion from camelCase to SNAKE_CASE __________________________________, "id1", "Game One", "RPG", 20, "id2", "Game Two", "FPS", 40)); trace("games from DB", GAME.query().tableData()); // trace data from DB http.get("/api/game", (header, body) -> { // GET call body.get(0).get("type").should(equal("RPG")); // specific field validation body.should(equal(table("*id", "title", // bulk validation _____________________, "id1", "Game One", "id2", "Game Two"))); }); } } > creating db datasource primary . created db datasource primary (94ms) > running DB update delete from GAME on primary-db . ran DB update delete from GAME on primary-db affected 2 rows (13ms) > inserting 2 rows into primary-db.GAME . inserted 2 rows into primary-db.GAME (10ms) > running DB query SELECT * FROM GAME on primary-db . ran DB query SELECT * FROM GAME on primary-db (6ms) [tracing] games from DB ID │ PRICE_USD │ TITLE │ TYPE "id1" │ 20.00 │ "Game One" │ "RPG" "id2" │ 40.00 │ "Game Two" │ "FPS" > executing HTTP GET http://localhost:8080/api/game . body[0].type equals "RPG" (0ms) . body equals *id │ title "id1" │ "Game One" "id2" │ "Game Two" (1ms) . header.statusCode equals 200 (1ms) response (application/json): [ {"id": __"id1"__, "title": __"Game One"__, "type": __"RPG"__, "priceUsd": 20.0}, {"id": __"id2"__, "title": __"Game Two"__, "type": "FPS", "priceUsd": 40.0} ] . executed HTTP GET http://localhost:8080/api/game (276ms)