(Originally posted on Medium)
This is the second article in my Expense Tracker series. In my introductory article, I covered the overall architecture and the topics this series will cover. Here, I discuss how I automated my tests.
It all Started with Postman
Expense Tracker isn’t actually my first project where I started learning about testing. I have another learning project written in Spring Boot, aptly named “Shopping App”. The pattern I started with was something like this:
- Write code for the controller
- Run the project with
gradlew bootRun - Open Postman
- Confirm the endpoint is working
Early on, this cycle already felt unnecessarily repetitive, and I knew there had to be a better way. I started by looking into Postman scripting, but then I thought, “Isn’t there a way I could do this natively in Java?”, and that’s when I realized what Unit Testing was about.
Writing a Basic Authentication Test

In this instance, I'm checking to see if sign up works at the basic level. What happens in the case of a username being taken or the password being insecure isn't my concern just yet, but here's the same flow translated to testing code using the Spring testing framework:
@Test
fun successfulSignUpFlow() {
val signUpRequest = SignUpRequest("username", "password", Locale.US.toLanguageTag());
val objectWriter = ObjectMapper().writer();
val signUpJson = objectWriter.writeValueAsString(signUpRequest);
val signUpResult = mockMvc
.perform(
MockMvcRequestBuilders.post("/auth/signUp")
.contentType(MediaType.APPLICATION_JSON)
.content(signUpJson)
)
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn();
assertTrue(signUpResult.getResponse().getContentAsString().startsWith("eyJ")) //pretty much constant for every JSON Base64
}
And the test result looks like this in IntelliJ:
Testing my code like this meant a few key things for me:
- I no longer had to use Postman to verify the correctness of my new code
- I now have an automated way to verify any previous tests I would have done manually
- I can specify all the relevant outcomes, such as HTTP 400 for an empty text field, or HTTP 404 for entities that don’t belong to the user, and be sure that they occur
I’ve read from a variety of sources to help improve my understanding of what testing should be about, and a key one has been Martin Fowler, especially his post “Given When Then”, which is about Behaviour Driven Development, or BDD, a term coined by Dan North. I’ll use an arithmetic example as I go along.
Before, when I thought about testing, all I could think up was trivial things like “Verify 1+1 = 2”, and it felt so trivial and pointless, and I wondered why people would be able to create tests like these. I did look at other examples, but I couldn’t understand what was going on back then. Now, with my current understanding, I see that it’s mainly about capturing expected behaviours as well as edge cases.
Let’s assume that this simple addition function is in Java, and we’re dealing with integers. Using int already eliminates the need for a few edge cases, but some other questions pop up:
- Are a and b both positive?
- What if a+b exceeds the 32-bit or 64-bit limit?
A few other questions might pop up if varied input is allowed:
- What about scientific notation?
- What about different bases like hexadecimal or binary?
This example is rather trivial, and the actual constraints for an app like this may be very different, but it does help me to illustrate some key things I’ve learned.
Tests serve as a form of documentation of the multiple possible ways to use a system. Based on the example above, some behaviors would look like this:
- Given a or b negative when add then return invalid input
- Given sum overflows when add return maximum positive number
- Given sum overflows when add return invalid input
- Given a and b in scientific when add then output is scientific
- Given a and b in when add hex then return hex
- Given a and b in different formats when add then return invalid input
- Given a and b in different formats when add then output in format of a
- Given a and b in different bases when add then output in higher base
Some of these are contradictory alternative responses to the same scenario, but it illustrates the point that there’s now no ambiguity in how I want things to work out.
I'm aware that there's another very popular format for test cases called "Arrange, Act, Assert", but I've chosen to stick with "Given When Then" since it also works.
Next, I’ll cover the various layers of my 3 code bases and how I applied testing to each of them.
The Database Layer
Known as the “Data Access Object” or “DAO” layer in the Android app and the “Repository” layer in the Spring project.
The tests I wrote here were mainly just to verify if my queries ran correctly whenever I went beyond simple column SELECTs such as with COUNT DISTINCT(), subqueries, or my personal favorite: WINDOW functions
Cumulative Sum
fun getTransactionCumSumByDate(
profileId: Long, debitOrCredit: Boolean
): List<DateAmountSummary> {
val window = DSL.orderBy(TRANSACTION.DATED_AT).rangeBetweenUnboundedPreceding().andCurrentRow()
var selectStep = dslContext.select(
TRANSACTION.DATED_AT.`as`("aggregateDate"),
DSL.sum(TRANSACTION.AMOUNT).over(window)
.`as`("sum"))
.from(TRANSACTION)
// Rest of selectStep
val alias = selectStep.asTable("x")
val selectFrom = dslContext.select(
alias.field("aggregateDate"),
DSL.max(alias.field("sum"))).from(alias)
val seekStep = selectFrom
.groupBy(alias.field("aggregateDate"))
.orderBy(alias.field("aggregateDate"))
val result = seekStep.fetch().into(DateAmountSummary::class.java)
return result
}
I was combining a window function with an aggregation via a subquery, and this was not very straightforward to me, so I wrote a test to help confirm that the number of rows and the final output was as expected:
@Test
fun cumSumByDateTest() {
val folder = //Temp folder for images
val firstTransactionDate = LocalDate.parse("2025-01-01")
val secondTransactionDate = LocalDate.parse("2025-01-02")
val thirdTransactionDate = LocalDate.parse("2025-01-04")
val fourthTransactionDate = LocalDate.parse("2025-01-04")
val fifthTransactionDate = LocalDate.parse("2025-01-05")
val accountId = getDefaultAccountId(userRepository, profileRepository, accountRepository)
val user = userRepository.findByUuid(TestConstants.DEFAULT_USER_ID)!!
val profile = profileRepository.findByStringId(user.id!!, Constants.DEFAULT_PROFILE_ID)
val dataBuilder = DataBuilder(folder, timeHandler, null, Constants.DEFAULT_PROFILE_ID, testUtilComponent)
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(110.00))
.atDate(firstTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(300.00))
.atDate(secondTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(450.00))
.atDate(thirdTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(750.00))
.atDate(fourthTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(1_000.00))
.atDate(fifthTransactionDate)
.commit()
val sums = transactionRepository.getTransactionCumSumByDate(profile!!.id!!, true, firstTransactionDate, null, emptyList(), emptyList(), emptyList() , emptyList(), emptyList())
val firstSum = sums.find { it.aggregateDate.compareTo(firstTransactionDate) == 0 }!!
val secondSum = sums.find { it.aggregateDate.compareTo(secondTransactionDate) == 0 }!!
val thirdSum = sums.find { it.aggregateDate.compareTo(thirdTransactionDate) == 0 }!!
assertEquals(4, sums.size)
assertEqualsBD(BigDecimal(110.00), firstSum.sum)
assertEqualsBD(BigDecimal(410.00), secondSum.sum)
assertEqualsBD(BigDecimal(1610.00), thirdSum.sum)
}
The Business Logic Layer
I chose to name them as the “Repository” layer in the Android app and the “Service” layer in the Spring project (confusing, I know)
Tests here were mainly to confirm if any transformations I applied to the raw data were valid, for example, when I get the transaction cumulative sum by date in this chart:
Since not every day within the date interval is guaranteed to have an entry, I'd like to interpolate those sub-intervals with the most recent amount so that the chart has a more natural-looking spread:
@Test
fun intervalCumSumTest() {
val folder = //Temp folder for images
val dataBuilder = DataBuilder(folder, timeHandler, null, Constants.DEFAULT_PROFILE_ID, testUtilComponent)
val user = userRepository.findByUuid(TestConstants.DEFAULT_USER_ID)!!
val profile = profileRepository.findByStringId(user.id!!, Constants.DEFAULT_PROFILE_ID)!!
val profileId = profile.id!!
val firstTransactionDate = LocalDate.parse("2025-01-02")
val secondTransactionDate = LocalDate.parse("2025-01-02")
val thirdTransactionDate = LocalDate.parse("2025-01-07")
val fourthTransactionDate = LocalDate.parse("2025-01-09")
val fifthTransactionDate = LocalDate.parse("2025-07-11")
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(110.00))
.atDate(firstTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(300.00))
.atDate(secondTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(450.00))
.atDate(thirdTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(750.00))
.atDate(fourthTransactionDate)
.commit()
dataBuilder.createTransaction()
.withItem("Description", "miscellaneous", BigDecimal(1000.00))
.atDate(fifthTransactionDate)
.commit()
// Here, transactionService is responsible for the interpolation
val transactionSumByDates = transactionService.getCumSumByDate(profileId, true, LocalDate.parse("2025-01-02"), null, emptyList(), emptyList(), emptyList(), emptyList(), emptyList())
val sum1 = transactionSumByDates.find { it.aggregateDate == firstTransactionDate }!!
var missingSum1 = transactionSumByDates.find { it.aggregateDate == LocalDate.parse("2025-01-03") }!!
val missingSum2 = transactionSumByDates.find { it.aggregateDate == LocalDate.parse("2025-01-08") }!!
val missingSum3 = transactionSumByDates.find { it.aggregateDate == LocalDate.parse("2025-01-10") }!!
assertEqualsBD(BigDecimal(410), sum1.sum)
assertEqualsBD(BigDecimal(410), missingSum1.sum)
assertEqualsBD(BigDecimal(860), missingSum2.sum)
assertEqualsBD(BigDecimal(1610), missingSum3.sum)
}
Accompanying charts:
The UI Layer
(Unique to Android)
My tests here were a mix of input validation, such as password constraints, and proper conditional behavior, such as the correct dialog popping up for invalid PDFs, but the test I want to highlight is about character count.
I went on a bit of a tangent (including but not limited to the Tonsky.me blog, X’s developer docs, StackOverflow), and decided that "Code Point" will be how I define what a "Character" is, and use it to determine my character limit, so my test here is to confirm that I’m counting and truncating graphemes correctly using ICU4J:
@Test
fun whenUserPastesTextAndTotalLengthOverCharacterLimitThenTextTruncatedByGrapheme() {
val context = ApplicationProvider.getApplicationContext<ExpenseTracker>()
//Assume max length is 500
// `text` is one code point short of that limit
val text = String(CharArray(Constants.MAX_NOTE_CODEPOINT_LENGTH - 1) {
'a'
})
val manRunning = "\uD83C\uDFC3\u200D\u2642\uFE0F" //4 codepoints - 1 surrogate pair + 3 regular
val manRunningCount = 4
// Copy to clipboard
addDetailedTransactionActivityScenario.onActivity {
it.runOnUiThread {
val clipBoardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
var clipData = ClipData.newPlainText("simple text", text + manRunning)
clipBoardManager.setPrimaryClip(clipData)
}
}
// Ctrl+V
onView(withId(R.id.edit_text_add_detailed_transaction_note)).perform(
click(), ViewActions.pressKey(
EspressoKey.Builder().withCtrlPressed(true).withKeyCode(KeyEvent.KEYCODE_V).build()))
onView(withId(R.id.edit_text_add_detailed_transaction_note)).check { view, noViewFoundException ->
val v = view as EditText
val text = v.text.toString()
val count = text.codePointCount(0, text.length)
// A simpler text limiter would have kept the first part of the surrogate pair, D83C, which has no sensible meaning
assertEquals(Constants.MAX_NOTE_CODEPOINT_LENGTH - 1, count)
}
onView(withId(R.id.text_view_add_detailed_transaction_note_length_indicator)).check(matches(withText("${Constants.MAX_NOTE_CODEPOINT_LENGTH - 1}/${Constants.MAX_NOTE_CODEPOINT_LENGTH}")))
//Similar test, but confirming with just emojis
val menRunning = manRunning.repeat(Math.floorDiv(Constants.MAX_NOTE_CODEPOINT_LENGTH, manRunningCount)+1) //4 x 126 = 504
addDetailedTransactionActivityScenario.onActivity {
it.runOnUiThread {
val clipBoardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
var clipData = ClipData.newPlainText("simple text", menRunning)
clipBoardManager.setPrimaryClip(clipData)
}
}
onView(withId(R.id.edit_text_add_detailed_transaction_note)).perform(
clearText(), ViewActions.pressKey(
EspressoKey.Builder().withCtrlPressed(true).withKeyCode(KeyEvent.KEYCODE_V).build()))
onView(withId(R.id.edit_text_add_detailed_transaction_note)).check { view, noViewFoundException ->
val v = view as EditText
val text = v.text.toString()
val count = text.codePointCount(0, text.length)
assertEquals(Constants.MAX_NOTE_CODEPOINT_LENGTH, count)
}
onView(withId(R.id.text_view_add_detailed_transaction_note_length_indicator)).check(matches(withText("${Constants.MAX_NOTE_CODEPOINT_LENGTH}/${Constants.MAX_NOTE_CODEPOINT_LENGTH}")))
onView(withId(R.id.edit_text_add_detailed_transaction_note)).perform(clearText())
}
And here’s the test being run by Espresso at 0.5 speed:
The Controller Layer
(Unique to Spring)
Very similar to the UI layer, it’s also mostly input validation, but instead of dialogs and error indicators, I check to see if I’m returning the correct status and custom error code:
@Test
fun givenCategoryIdExistsAndCategoryIdBelongsToAnotherUserWhenCreateTransactionThenNotFound() {
val user = userRepository.findByUuid(TestConstants.DEFAULT_USER_ID)!!
val profile = profileRepository.findByStringId(user.id!!, Constants.DEFAULT_PROFILE_ID)!!
val userId = userService.createUser("user2", "password", Locale.US)
val profile2 = profileRepository.findByStringId(userId, Constants.DEFAULT_PROFILE_ID)!!
val accounts = accountRepository.getAllByProfileId(profile.id!!)
val misc = categoryRepository.findByProfileIdAndStringId(profile2.id!!, "miscellaneous")
var basicTransactionRequest = BasicTransactionCreateRequest(accounts[0].id!!, true, BigDecimal.TEN, "Description", misc!!.id, "Etc/UTC")
val jws = createBearerToken("username", jwtSigningKey!!, JWT_ALG, timeHandler)
val objectWriter: ObjectWriter = ObjectMapper().writer()
val json: String = objectWriter.writeValueAsString(basicTransactionRequest)
mockMvc
.perform(
MockMvcRequestBuilders.post("/transactions/basic")
.header("Authorization", "Bearer ${jws}")
.header("Profile-Id", profile.stringId)
.contentType(MediaType.APPLICATION_JSON)
.content(json)
)
.andExpectAll(
//Error code should be 404
MockMvcResultMatchers.status().isNotFound,
MockMvcResultMatchers.jsonPath(
"$.$CUSTOM_PROBLEM_DETAILS_ATTRIBUTE_ERROR_CODE",
//Make sure correct descriptive error code is returned
Matchers.equalTo(ErrorCode.ENTITY_NOT_FOUND.code)
)
)
}
Now that I’ve covered the various layers, I’ll talk about 3 more issues that came up while I was learning:
Decision Table
I usually come up with my tests in my head, basically. I start with one possibility, like:
Given Number field is empty…
Then I’d expand to another one like:
Given Number field is non-positive…
And it’s worked fine for most of my tests, but for the edit feature of Expense Tracker, this unfortunately wasn’t enough.
For a brief overview, in the edit feature:
- The
dbIdproperty determines if an item is “new” or “existing” - Similarly, images also have a
dbIdproperty as well asdbIsLinkedto indicate whether there was already an entry in the database that linked the image to the item
data class AddTransactionItem(
val id: Int,
val dbId: Long?,
val category: CategoryUi,
val amount: BigDecimal? = null,
val description: String? = null,,
val images: List<AddEditTransactionFile> = emptyList(),
val deletedDbImages: List<AddEditTransactionFile> = emptyList()
)
data class AddEditTransactionFile(
val dbId: Long?,
val uri: Uri,
val mimeType: String,
val sha256: String,
val sizeBytes: Long,
val dbIsLinked: Boolean = false
)
In this example screen, the first item is from the database, and the orange has a non-null dbId and dbIsLinked is set to true, while the second item is new and so has a null dbId .
My main issue was knowing what to do when saving the changes of that edit. I needed to know how to deal with images that satisfied my main constraints:
- If the same image is added to more than one item, then simply link them instead of adding duplicates
- If the image has been removed from an item, and that was the last item it was linked to, remove it from the user’s device
Coming up with test cases for this felt complicated, simply put. I looked at Karnaugh maps and Finite State machines because I felt they would help out, but in the end, I settled for something that loosely resembles a truth table, which I later found to be called a Decision table.
I itemized my variables like this:
- Is the item new or in the database?
- Has the item been marked as deleted?
- Is the image new or in the database?
- Has the image been marked deleted?
- Does the image have a database link to the item?
With a clear idea of what they were, I could now go through all the possibilities. Since there are 5 variables in total, there are 2⁵=32 possibilities*
(* It’s possible for the variable to have more than 2 states, but I haven’t encountered a case like that yet)
For the sake of brevity, I’ll show the first 8 rows.
- Rows 3 and 5 are marked “Impossible” because of the condition: not( ImageInDb ) and ImageLinked
- Similarly, 7 and 9 are marked that way because of not( ItemInDb ) and ImageLinked
- Rows 4 and 8 don’t need any action because I don’t care about images that won’t be linked to an item
- Row 1 means that I have to persist the image to the database first before I create a link to the item, and row 6 means I skip the first part
The edit feature so far has been the only time where I’ve really needed the table, but I’m glad I got to use it.
Overall, this method gave me some notable benefits:
- A way to mark impossible states and relevant states. In my case, I only needed to use 8 of the 32 rows, but I got to know which ones were which
- Those 8 rows can each become a unit test
Seeding Data
As the number of my tests grew, I found myself repeatedly having to put custom entries in the database through the repository methods. This ended up taking up a good portion of my test code in some cases.
I wanted something that would simplify this process so that I could focus on testing outcomes instead of creating data. The idea mainly came from the OpenAPI generator code base, where I did a bit of work. They use various utilities such as CodegenConfigurator to make it so that configuration doesn’t take as long. After some searching, one of my main helps was this article from Lukas Eder of jOOQ:
And I ended up coming up with my own basic DSL to help me out:
interface Commit {
/**
* Returns the list of transaction IDs
*/
fun commit(): List<Long>
}
interface TransactionBuilder: Commit {
fun withItem(description: String): TransactionBuilder
fun withItem(description: String, category: String, amount: BigDecimal): TransactionBuilder
fun withDefaultItemPrice(defaultPrice: BigDecimal): TransactionBuilder
fun withNewAccount(currencyCode: String, accountName: String): TransactionBuilder
fun debitOrCredit(debitOrCredit: Boolean): TransactionBuilder
fun withDetailedItem(): TransactionItemBuilder
fun repeatIntoDates(dates: List<LocalDate>): TransactionBuilder
/**
* Inclusive, Inclusive
*/
fun repeatIntoDateRange(startDate: LocalDate, endDate: LocalDate): TransactionBuilder
fun atDate(date: LocalDate): TransactionBuilder
}
interface TransactionItemBuilder: Commit {
fun mainDetails(description: String, category: String, amount: BigDecimal): TransactionItemBuilder
fun otherDetails(brand: String?, quantity: Int, variation: String, referenceNumber: String?): TransactionItemBuilder
fun withImages(vararg resource: TestData.Resource): TransactionItemBuilder
fun build(): TransactionBuilder
}
Relevant commit link
And in a test to verify that I’m fetching the statistics for accounts correctly, here’s an example where I make data seeding slightly more readable:
Setup and Teardown
For setup, I have utility methods to insert basic data for a default profile.
For teardown, I chose to use an approach where I essentially delete the whole database when a test is finished. In Android, it’s simple with Room’s clearAllTables , but in the backend, there’s no direct utility method I found, so instead I did this:
- Generated documentation with SchemaSpy
- Looked at the
deletionOrder.txtfile - Wrote
DELETEmethods that corresponded to each table and ran them in the same order
Testcontainers are probably a better way to handle this, and Spring’s Transactional annotation as well, but I’m sticking with the current method since it’s not too much trouble
Conclusion
Overall, I’ve gained some key things:
- A framework for identifying edge cases — even the rare ones — ensuring they are handled by design rather than by accident.
- Increased confidence that when I make any changes to a complicated feature like transaction editing, I likely haven’t broken existing behavior
- A shorter feedback loop
There are also other kinds of tests, like end-to-end, acceptance, and integration tests, but I think those topics are different enough to not have to discuss here, especially since the meaning of some can change significantly per environment. I do talk about performance testing, though, but that’s for later.
Now that I’ve learned how to automate my tests, next I’ll talk about how I’ve learned to automate my deployments using Continuous Integration with Jenkins.
Please share your experiences with how testing and test culture is (or is not) implemented in your own code bases in the comments.
Thank you for your time.







Top comments (0)