CS 346 (W23)
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Dependencies

The idea of a dependency is central to our understanding of how to isolate a particular class or set of classes for testing.

When you are examining a software component, we say that your component may be dependent on one or more other software entities to be able to run successfully. For example, you may need to link in another library, or import other classes, or connect to another software system (like a database). Each of these represents code that affects how the code being tested will execute.

We often call the external software component or class a dependency. That word describes the relationship (classes dependent on one another), and the type of component (a dependency with respect to the original class).

A key strategy when testing is to figure out how to control these dependencies, so that you’re exercising your class independently of the influence of other components.

We can further differentiate these dependencies:

Managed vs. Unmanaged dependencies. There is a difference between those that we control directly (managed), vs. those that may be shared with other software. A managed dependency suggests that we control the state of that system.

  • Examples: a library that is statically linked is managed; a dynamically linked library is not. A database could be single-file and used only for your application (managed) or shared among different applications (unmanaged).

Internal vs. External dependencies. Running in the context of our process (internal) or out-of-process (external). A library is internal, a database is typically external.

  • Examples: A library, regardless of whether it is managed, is internal. A database, as a separate process is always external.

It is important to think about these distinctions for our dependencies because they affect what we can and cannot control during testing. For example, an unmanaged dependency means that we do not control it’s state and we may not be able to test that specific component or dependency. The best we could do it test our interface against it, since we may not be able to trust the results of any actions that we take against it.

Generally speaking, we cannot test unmanaged dependencies since we cannot control them. We also tend to be limited in our ability to test external systems, since we do not manage their state.

Test doubles

To achieve isolation in testing, we often create test doubles — classes that are meant to look like a dependent class, but that don’t actually implement all of the underlying behaviour. This lets us swap in these “fake” classes for testing.

There are five principal kinds of test doubles: Dummies, Fakes, Stubs, Spies and Mocks.

Test Doubles

Mocks are “objects pre-programmed with expectations which form a specification of the calls they are expected to receive.” – Martin Fowler, 2006.

A mock is a fake object that holds the expected behaviour of a real object but without any genuine implementation. For example, we can have a mocked File System that would report a file as saved, but would not actually modify the underlying file system.

You can fairly easily create these mock classes yourself for code domain objects.

Several libraries have also been established to help create mocks of objects. Mockito is one of the most famous, and it can be complemented with Mockito-Kotlin. Here’s an example of a mock File that reports a path, but doesn’t actually do anything else.

private val mockedFile: File {
	return mock { on { absolutePath} doReturn "/random"}
}

The value in mocks is that they break the dependency between your method-under-test (MUT) and any external classes, by replacing the external dependency with a “fake class” with predetermined behaviour that helps you test.

The value of interfaces

One important recommendation is that you introduce interfaces for out-of-class dependencies. For instance, you will often see code like this:

public interface IUserRepository
public class UserRepository : IUserRepository

This is common when testing, even in cases when that class may represent the only realization of an interface. This allows you to easily write mocks against the interface, where it’s relatively easy to determine what expected behaviour should be.

Dependency injection

The second half of this is dependency injection. This is the practice of supplying dependencies to an object in it’s argument list instead of allowing the object to create them itself. If you design your classes this way, then it’s easy to create an instance of a mock and provide that to your function, instead of allowing the function to instantiate the object itself.

Dependency injection is basically providing the objects that an object needs (its dependencies) instead of having it construct them itself. It’s a very useful technique for testing, since it allows dependencies to be mocked or stubbed out.

https://stackoverflow.com/questions/130794/what-is-dependency-injection

For instance, we might have a class that uses a database for persistence:

class Persistence {
  val repo = UserRepository() // dependency
  
  fun saveUserProfile(val user: User) {
    repo.save(user)
  }
}

val persist = Persistence()
persist.saveUserProfile(user) // save using the real database

Imagine that we want to test our saveUserProfile method. This would be extremely difficult to do cleanly, since we have a dependency on the repo that our Persistance class creates.

We could instead change our Persistence class so that we pass in the dependency. This allows us to control how it is created, and even replace the UserRepository() with a mock.

class Persistence(val repo: IUserRepository) {
    fun saveUserProfile(val user: User) {
    repo.save(user)
  }
}

class MockRepo : IUserRepository { 
	// body with functions that mirror how the repo would work
  // but no real implementation
}
val mock = MockRepo()
val persist = Persistance(mock)
persist.saveUserProfile(user) // save using the mock database

Obviously you don’t want to mock everything - that would turn this into a unit test! However, you should use this technique to isolate the classes that you are using, and test just the intended path.