Writing tests
Tegral DI provides many patterns that can be used for writing tests.
Subject-based testing
Tegral DI provides a class that can be used by your test classes to automatically create very flexible test environments. Let's imagine we want to test our UserRegistrationService
class, as follows:
class RegistrationException(message: String) : Exception(message)
class UserRegistrationService(scope: InjectionScope) {
private val userRepository: UserRepository by scope()
private val permissionsService: PermissionsService by scope()
fun registerUser(registration: UserRegistrationData) {
when {
!registration.isRegistrationValid() ->
throw RegistrationException("Registration is invalid")
!permissionsService.isUserAllowedToRegister(registration) ->
throw RegistrationException("User is not allowed to register")
else ->
userRepository.createFrom(registration)
}
}
}
Let's say we want to test the registerUser
function. We can create a test class like this:
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class,
tegralDiModule {
put { UserRegistrationService() }
}
) {
// ...
}
The TegralSubjectTest
class is based around the idea of a "test subject" -- that is, some component that we are trying to test. In this case, this is the UserRegistrationService
class. We need to provide:
The
KClass
for this service.A base module that will be used to create the test subject and any additional objects you will always need in the environment within your tests. This one can be initialized in a few ways:
By directly providing a module (either by using one that you already made before or by creating a new one via
tegralDiModule
).By providing a lambda that will create the module. This basically means passing the lambda you'd use with
tegralDiModule
, but without thetegralDiModule
that goes with it.By providing a constructor. This is useful if you only need the test subject within your test, and that subject's constructor is simple, similiar to using
put { ... }
versusput(::...)
. The class is automatically extracted from the function's signature. Note that this approach may be more bug-prone and less flexible than the above ones.
// Using a lambda
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class, { put { UserRegistrationService() } }
) {
// ...
}
// Using a constructor reference
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
::UserRegistrationService
) {
// ...
}
The test
function
Once you have set up your class, you can use the test
function to automatically create an injection environment.
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class,
tegralDiModule {
put { UserRegistrationService() }
}
) {
@Test
fun `Test fails if registration is invalid`() = test {
val registration = mockk<UserRegistrationData> {
every { isRegistrationValid() } returns false
}
put { registration }
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify { registration.isRegistrationValid() }
}
}
In this example, we're using MockK to create a mocked user registration data object where isRegistrationValid
always returns false. Then, we add it to the injection environment (using put
). We then call registerUser
and assert that it throws a RegistrationException
, as wrong user registrations should trigger this kind of exception. We then use MockK's verify
function to ensure that the isRegistrationValid
function was called.
You have access to all the usual environment methods (get
and createInjector
) and all DSL component creation methods (the put
function family) within the test
block. subject
is a shortcut for get
ting the test subject: in this example, it's equivalent to get<UserRegistrationService>()
.
Managing mocks
Since the pattern of "mock (or create something) and put it in the environment" is common, you can save a line by using .alsoPut()
instead of a separate put
call. This is equivalent to calling .also { put(it) }
, but uses a nicer format.
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class,
tegralDiModule {
put { UserRegistrationService() }
}
) {
@Test
fun `Registration fails if data is invalid`() = test {
val registration = mockk<UserRegistrationData> {
every { isRegistrationValid() } returns false
}.alsoPut()
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify { registration.isRegistrationValid() }
}
}
And for an even easier time, you can use the tegral-di-test-mockk
library that adds a putMock
function that does all of this in one go:
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class,
tegralDiModule {
put { UserRegistrationService() }
}
) {
@Test
fun `Registration fails if data is invalid`() = test {
val registration = putMock<UserRegistrationData> {
every { isRegistrationValid() } returns false
}
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify { registration.isRegistrationValid() }
}
}
You can also create your own extension function that does all of this for you if you are using libraries other than MockK. For example, here is a simplified version of the implementation provided by tegral-di-test-mockk
:
inline fun <reified T> UnsafeMutableEnvironment.putMockk(block: T.() -> Unit): T =
mockk(block = block).alsoPut()
We can then continue on with this pattern and mock the behaviors we need for the three cases in our registerUser
function.
@Test
fun `Registration fails if data is invalid`() = test {
val registration = putMockk<UserRegistrationData> {
every { isRegistrationValid() } returns false
}
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify { registration.isRegistrationValid() }
}
@Test
fun `Test fails if registration is invalid`() = test {
val registration = putMockk<UserRegistrationData> {
every { isRegistrationValid() } returns true
}
val perms = putMockk<PermissionsService> {
every { isUserAllowedToRegister(registration) } returns false
}
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify {
registration.isRegistrationValid()
perms.isUserAllowedToRegister(registration)
}
}
@Test
fun `Registration succeeds and calls repository`() = test {
val registration = putMockk<UserRegistrationData> {
every { isRegistrationValid() } returns true
}
val perms = putMockk<PermissionsService> {
every { isUserAllowedToRegister(registration) } returns true
}
val repo = putMockk<UserRepository> {
every { registerUser(registration) } just runs
}
assertDoesNotThrow {
subject.registerUser(registration)
}
verify {
registration.isRegistrationValid()
perms.isUserAllowedToRegister(registration)
repo.registerUser(registration)
}
}
Mock in module, specs in test
One possible pattern is to mock an object that is almost always used as a dependency in your test subject within the base module. Using MockK, you can then add more behavior to this mock on the fly, within tests where such behavior matters.
class UserRegistrationServiceTest : TegralSubjectTest<UserRegistrationService>(
UserRegistrationService::class, {
put { UserRegistrationService() }
put { mockk<UserRegistrationData>() }
}
) {
@Test
fun `Registration fails if data is invalid`() = test {
val registration = get<UserRegistrationData>().apply {
every { isRegistrationValid() } returns false
}
assertThrows<RegistrationException> {
subject.registerUser(registration)
}
verify { registration.isRegistrationValid() }
}
}
Running tests in parallel
Tegral DI's environments are strongly scoped: that is, they live independently of each other without any global state. This means that, as long as your own classes do not rely on a kind of "global state", your tests can be ran in parallel without issues.
Manually creating a test environment
While subject-based testing is handy, you may want to create an environment yourself without using this pattern. In this case, you can create environments as usual and use the UnsafeMutableEnvironment
environment type.
val env = tegralDi(UnsafeMutableEnvironment) {
put(::MyComponent)
put(::MyOtherComponent)
}
env.put(...)
env.get<...>(...)
This is the same kind of environment you get from subject-based tests.