Chapters

Hide chapters

Android Test-Driven Development by Tutorials

First Edition · Android 10 · Kotlin 1.3 · AS 3.5

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section II: Testing on a New Project

Section 2: 8 chapters
Show chapters Hide chapters

Section III: TDD on Legacy Projects

Section 3: 9 chapters
Show chapters Hide chapters

6. Architecting for Testing
Written by Fernando Sproviero

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Software architecture is a template or blueprint that you can use when building new apps. It defines software elements and their relations. When you create a new app, you need to make some fundamental structural decisions — and those decisions may be difficult to modify once they’re implemented.

In this chapter, you’ll focus on what it takes to architect an app for testability; specifically, you’ll:

  • Learn the characteristics of a testable architecture.
  • Discover good practices to create a testable architecture.

How does architecture matter?

To understand why architecture matters, it’s essential first to understand what qualifies as a poorly architected app.

A poorly architected app may have all of its logic contained within a single method that consists of many lines; or it may have one large class with too many responsibilities. Both of these scenarios make it impossible to test groups or units of logic independently.

Apps that are architected for testing separate their code into groups of logic using multiple methods and classes to collaborate. With this type of architecture, developers can test each public method and class in isolation.

You also need to consider the effort it takes when adding or modifying an app’s features. In TDD, this process starts with creating new tests or modifying existing ones. While it may take some additional time to do this, adding and updating tests shouldn’t be a painful process. If it is, you’ll eventually stop writing tests and avoid TDD all together. To encourage TDD, it’s better to think of a software architecture that encourages and facilitates the creation of tests.

But it’s not only testing that matters:

  • Communication: Software architecture establishes a common language between the developers of an app and other members of the team, like managers, QA testers, analysts and designers.

  • Reusable abstraction: Reusability saves time. Later in the chapter, you’ll see that you can reuse patterns within different parts of an app, across different apps as well as on other platforms. You’ll also see that you can use architecture patterns to kick-off new projects.

  • Early design decisions: When you create a new app, one of the first decisions is to decide on the architecture you’re going to use. These early design decisions are important because they’ll set constraints on your implementation, such as the way your classes will interact and their responsibilities. Early decisions will also organize your codebase a specific way and may even organize the members of your team. For example, on a given architecture, you may divide your team between people who only write domain classes and others who only write visually-related code.

  • Better testing: By using good architecture from the start or refactoring an existing one, you’ll enable the creation of tests that would otherwise be impossible or difficult to write. Also, migrating from an existing architecture to a better one — which is a difficult task, but not impossible — will enable you to migrate slower tests, such as UI or integration tests, to unit tests, which are faster.

To achieve a robust architecture, it’s important to know and understand design patterns and the SOLID principles.

Design patterns

It’s not uncommon for developers to encounter the same problems in different projects and platforms, and to solve these problems using similar solutions. Over time, certain developers started formalizing these patterns into templates or solutions that other developers could reuse if they found themselves in a similar context or situation.

Creational

The patterns in the Creational category describe solutions related to object creation.

Singleton

The Singleton design pattern specifies that only one instance of a certain class may exist, known as a singleton. Usually, it’s possible to access the singleton globally.

object MySingleton {
  private var status = false
  private var myString = "Hello"

  fun validate(): Boolean {
    ...
  }

  ...
}
MySingleton.validate()
class MyClass {
  fun methodA() {
    ...
    if (MySingleton.validate()) {
      ...
    }
    ...
  }
}

Builder

The Builder design pattern abstracts the construction of a complex object, joining several parts. For example, think of a restaurant that serves different menus depending on the day.

abstract class MenuBuilder {
  var menu = Menu()
  abstract fun buildMainDish()
  abstract fun buildDessert()
}
class DayOneMenuBuilder: MenuBuilder() {
  override fun buildMainDish() {
    // Add day one main dish to the menu
  }
  override fun buildDessert() {
    // Add day one desert to the menu
  }
}

class DayTwoMenuBuilder: MenuBuilder() {
  ...
}
class Chef {
  fun createMenu(builder: MenuBuilder): Menu {
    builder.buildMainDish()
    builder.buildDessert()
    return builder.menu
  }
}
val chef = Chef()
val menuBuilder = getDayMenuBuilder()
val menu = chef.createMenu(menuBuilder)
AlertDialog.Builder(this)
  .setTitle("Error!")
  .setMessage("There was an error, would you like to retry?")
  .setNegativeButton("Cancel", { dialogInterface, i ->
    ...
  })
  .setPositiveButton("Retry", { dialogInterface, i ->
    ...
  })
  .show()

Dependency Injection

The Dependency Injection design pattern is crucial to having a testable architecture.

class Vehicle() {
  private val engine = CombustionEngine()

  fun start(): Boolean {
    ...
    return engine.start()
  }
  ...
}
class Vehicle(val engine: Engine) {
  fun start(): Boolean {
    ...
    return engine.start()
  }
  ...
}
class CombustionVehicleBuilder {
  fun build(): Vehicle {
    val engine = CombustionVehicleEngine()
	...
	return Vehicle(engine)
  }
}
class Vehicle {
  var engine: Engine? = null
  ...
  fun start(): Boolean {
    engine?.let {
	   return engine.start()
	  }
    return false
  }
  ...
}
class MyActivity : AppCompatActivity() {
  private lateinit var repository: Repository

  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    repository = (application as MyApplication).getRepository()
    ...
  }
}
class MyApplication : Application() {
  fun getRepository(): Repository {
    val apiService = getApiService()
    val inMemoryService = getInMemoryService()
    return MyRepository(apiService, inMemoryService)
  }
  ...
}

Structural

Structural design patterns ease the design to establish relationships between objects.

Adapter (or Wrapper)

The Adapter (or Wrapper) design pattern describes how to let two incompatible classes work together.

class ContactsAdapter(private val contacts: List<Contact>):
  RecyclerView.Adapter<ContactViewHolder>() {

  override fun onCreateViewHolder(viewGroup: ViewGroup,
     							  i: Int): ContactViewHolder {
    val inflater = LayoutInflater.from(viewGroup.context)
    val view = inflater.inflate(
      R.layout.row_contact, viewGroup, false
    )
    return ContactViewHolder(view)
  }

  override fun onBindViewHolder(viewHolder: ContactViewHolder,
                                i: Int) {
    viewHolder.bind(contacts[i])
  }

  override fun getItemCount() = contacts.size

  inner class ContactViewHolder(itemView: View):
    RecyclerView.ViewHolder(itemView) {

    fun bind(contact: Contact) {
      ...
    }
  }
}

Facade

The Facade design pattern defines a high-level interface object which hides the complexity of underlying objects. Client objects prefer using the facade instead of the internal objects because the facade provides a cleaner, easier-to-use interface.

class ProductsRepository {
  ...
  fun getProducts(): List<Product> {
    if(isRemoteAvailable) {
        return api.getProducts()
    } else {
      val localProducts = room.getProducts()
      if(localProducts.isEmpty()) {
        return sharedPrefsManager.getLastProduct()
      } else {
          return localProducts
      }
    }
  }
}

Composite

The intent of the Composite design pattern is to construct complex objects composed of individual parts, and to treat the individual parts and the composition uniformly.

Behavioral

Behavioral design patterns explain how objects interact and how a task can be divided into sub-tasks among different objects. While creational patterns explain a specific moment of time (the creation of an instance), and structural patterns describe a static structure, behavioral patterns describe a dynamic flow.

Observer

The Observer design pattern gives you a way to communicate between objects where one object informs others about changes or actions. There’s an observable object which you can observe, and there’s one or more observer objects that you use to subscribe to the observable.

button.setOnClickListener(object: View.OnClickListener {
  override fun onClick(v: View?) {
    // Perform some operation
  }
})

Command

The Command design pattern describes the encapsulation of an operation without knowing the real content of the operation or the receiver.

Architectural design patterns

There are some other design patterns that may be considered as a fourth category. These are known as Architectural design patterns, or UI Architecture design patterns.

MVC

Model-View-Controller (MVC) states that each class you write should be part of one of the following layers:

data class Address(val street: String,
				   val number: String,
				   val zipCode: String)
addressView.text =
  "${address.street}, ${address.number}, ${address.zipCode}"
data class Address(val street: String,
				   val number: String,
				   val zipCode: String) {
  val description = "$number, $street, $zipCode"
}
addressView.text = address.description

MVP

Model-View-Presenter (MVP) has these layers:

interface LoginPresenter {
  fun login(username: String, password: String)
}

interface LoginView {
  fun showLoginSuccess()
  fun showLoginError()
  fun showLoading()
}
class LoginPresenterImpl(
  private val repository: LoginRepository,
  private val view: LoginView): LoginPresenter {

  override fun login(username: String, password: String) {
    view.showLoading()
    repository.login(username, password, object : Callback {
      override fun onSuccess() {
        view.showLoginSuccess()
      }
      override fun onError() {
        view.showLoginError()
      }
    })
  }
}

class LoginActivity: AppCompatActivity(), LoginView {

  private lateinit var presenter: LoginPresenter

  override fun onCreate(savedInstanceState: Bundle?) {
    ...

    loginButton.setOnClickListener {
      presenter.login(usernameEditText.text.toString(),
                      passwordEditText.text.toString())
    }
  }

  override fun showLoginSuccess() { ... }
  override fun showLoginError() { ... }
  override fun showLoading() { ... }
}
@Test
fun login_shouldShowLoading() {
  var didShowLoading = false

  val testRepository = object: LoginRepository { ... }

  val testView = object: LoginView {
    override fun showLoginSuccess() {}
    override fun showLoginError() {}
    override fun showLoading() { didShowLoading = true }
  }

  val presenter = LoginPresenterImpl(testRepository, testView)

  presenter.login("Foo", "1234")

  Assert.assertTrue(didShowLoading)
}

MVVM

Model-View-ViewModel (MVVM) contains the following layers:

class LoginViewModel(
  private val repository: LoginRepository): ViewModel() {

  private val loginStatus: MutableLiveData<LoginStatus>()

  fun getLoginStatus(): LiveData = loginStatus

  fun login(username: String, password: String) {
    loginStatus.value = LoginStatus.Loading()
    repository.login(username, password, object : Callback {
      override fun onSuccess() {
        loginStatus.value = LoginStatus.Success()
      }
      override fun onError() {
        loginStatus.value = LoginStatus.Error()
      }
    })
  }
}

sealed class LoginStatus {
  class Error(): LoginStatus()
  class Success(): LoginStatus()
  class Loading(): LoginStatus()
}
class LoginActivity: AppCompatActivity(), LoginView {

  private lateinit var viewModel: LoginViewModel

  override fun onCreate(savedInstanceState: Bundle?) {
    ...
    viewModel = ...

    viewModel.getLoginStatus().observe(this, Observer {
      when(it) {
        is LoginStatus.Loading -> ...
        is LoginStatus.Success -> ...
        is LoginStatus.Error -> ...
      }
    })

    loginButton.setOnClickListener {
      viewModel.login(usernameEditText.text.toString(),
                      passwordEditText.text.toString())
    }
  }
}

S.O.L.I.D principles

TDD is closely related to good programming practices. Writing tests before the actual feature makes you think on how the interface of a class will be. Therefore, you’ll be exposing only those methods really needed. On the contrary, without using TDD, sometimes you’ll find yourself creating a class and exposing methods and properties that you don’t need them to be public. Using TDD will also make you think on how the classes will collaborate. After writing several features, while your app grows, there will be times when you realize that things should be refactored. Thanks to having tests you can refactor with confidence.

Single responsibility (SRP)

Each class should have a unique objective or should be useful for a specific case. Any logic that is not part of the objective of the class should be the responsibility of some other class. A class that has lots of responsibilities is sometimes called a god class and should be avoided.

Open-closed

This principle was actually first defined by Bertrand Meyer in his book Object-Oriented Software Construction.

data class Room(val width: Double, val height: Double)

class ArchitectUtils {
  ...
  fun calculateArea(rooms: List<Room>) {
    var total = 0
    for (room in rooms) {
      total += room.width * room.height
    }
    return total
  }
}
interface Space

data class RectangularSpace(val width: Double, val height: Double): Space

data class CircularSpace(radius: Double): Space

class ArchitectUtils {
  ...
  fun calculateArea(spaces: List<Space>) {
    var total = 0
    for (space in spaces) {
      if (space is SquareSpace) {
        total += space.width * space.height
      } elseif (space is CircularSpace) {
        total += space.radius * space.radius * PI
      }
    }
    return total
  }
}
interface Space {
  fun area(): Double
}

data class RectangularSpace(val width: Double, val height: Double): Space {
  override fun area() = width * height
}

data class CircularSpace(radius: Double): Space {
  override fun area() = radius * radius * PI
}

class ArchitectUtils {
  ...
  fun calculateArea(spaces: List<Space>) {
    var total = 0
    for (space in spaces) {
      total += space.area()
    }
    return total
  }
}

Liskov substitution

Also called design by contract, was initially introduced by Barbara Liskov in a 1987 conference keynote titled Data abstraction and hierarchy. Basically, it states that an app that uses an object of a base class should be able to use objects of derived classes without knowing about that and continue working. Therefore, your code should not be checking the subtype. In the subclass you can override some of the parent methods as long as you continue to comply with its semantics and maintain the expected behavior. As you can see, if you respect the contract, the app should continue to work.

private const val WIDTH = 4
private const val HEIGHT = 3

private fun assertArea(rectangle: Rectangle) {
  Assert.assertTrue(WIDTH * HEIGHT, rectangle.area())
}

@Test
fun testAreaRectangle() {
  val rectangle = Rectangle()
  rectangle.width = WIDTH
  rectangle.height = HEIGHT

  assertArea(rectangle) // This test will pass
}

@Test
fun testAreaSquare() {
  val square = Square()
  square.width = WIDTH
  square.height = HEIGHT // This will also set square.width to HEIGHT

  assertArea(square) // Therefore, this test will fail, because area is 9
}
interface Repository {
  fun findContactOrNull(id: String): Contact?
}

class InMemoryRepository: Repository {
  private lateinit var cachedContacts: Map<String, Contact>
  ...
  fun findContactOrNull(id: String): Contact? {
    return cachedContacts[id]
  }
}

class SqlRepository: Repository {
  fun findContactOrNull(id: String): Contact? {
    val contact = // Implementation to get it from a SQL DB
    return contact
  }
}

Interface segregation

This principle encourages you to create fine grained interfaces that are client specific. Suppose you have a class with a few methods, one part of your app may only need to access a subset of your methods and other part may need to access another subset. This principle encourages you to create two interfaces. Clients should have access to only what they need and nothing more.

interface Membership {
  fun login(username: String, password: String): User
  fun logout(user: User)
  fun register(username: String, password: String)
  fun forgotPassword(username: String)
}
interface Login {
  fun login(username: String, password: String): User
  fun logout(user: User)
}
interface Register {
  fun register(username: String, password: String)
}
interface Forgot {
  fun forgotPassword(username: String)
}
interface Printer {
  fun print(file: File)
  fun scan(): Bitmap
  fun sendTo(file: File, email: String)
}
class FullPrinter: Printer {
  override fun print(file: File) {
    // Implementable logic
  }
  override fun scan(): Bitmap {
    // Implementable logic
  }
  override fun sendTo(file: File, email: String) {
    // Implementable logic
  }
}
class MobileDevice: Printer {
  override fun print(file: File) {
    throw UnsupportedOperationException()
  }
  override fun scan(): Bitmap {
    // Implementable logic
  }
  override fun sendTo(file: File, email: String) {
    // Implementable logic
  }
}
interface Printer {
  fun print(file: File)
}

interface Scanner {
  fun scan(): Bitmap
  fun sendTo(file: File, email: String)
}
class FullPrinter: Printer, Scanner {
  override fun print(file: File) {
    // Implementable logic
  }
  override fun scan(): Bitmap {
    // Implementable logic
  }
  override fun sendTo(file: File, email: String) {
    // Implementable logic
  }
}

class Mobile: Scanner {
  override fun scan(): Bitmap {
    // Implementable logic
  }
  override fun sendTo(file: File, email: String) {
    // Implementable logic
  }
}

Dependency inversion

This principle states that a concrete class A should not depend on a concrete class B, but an abstraction of B instead. This abstraction could be an interface or an abstract class.

class ApiDataFetcher {
  fun fetch(): Data {
    // Implementation that retrieves data from an API
  }
}

class MyPresenter(private val apiDataFetcher: ApiDataFetcher) {
  fun getData(): Data {
    ...
	return apiDataFetcher.fetch()
  }
  ...
}
interface DataFetcher {
  fun getData(): Data
}

class ApiDataFetcher: DataFetcher {
  fun fetch(): Data {
    // Implementation that retrieves data from an API
  }
}

class MyPresenter(private val dataFetcher: DataFetcher)

Key points

  • Use software architecture to communicate development standards between team members.
  • It’s not uncommon to reuse software architecture on different projects.
  • When starting a new project, one of the first decisions is to decide on its software architecture.
  • Proper software architecture helps with testing.
  • Support your software architecture using design patterns and the SOLID principles.
  • Design patterns are classified into three categories: creational, structural and behavioral.
  • The dependency injection pattern is the key to having a testable architecture.
  • There are other user interface design patterns such as MVC, MVP and MVVM.

Where to go from here?

In the next chapter, you’ll continue writing tests using Mockito and the MVVM architecture that is suggested by Google, which uses ViewModel and LiveData.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now