Unit testing: essential and complicated at the same time

Hi, I am Arnaud!

Software engineer - Freelancer

@arnolanglade

Manual tests

Feature code

Tools

When I started coding...

Spend more time on debugging than coding

Have to code the whole feature to test/try it

drawbacks

ionicons-v5-k

I have to chose my tools before starting coding

Then I discovered software testing!

ionicons-v5-p

There are several

kinds of tests

System/End-to-end testing

Integration/Contract testing

Unit testing

Load testing

Performance testing

Manual testing

What is a Unit test?

Unit tests are automated tests written and run by software developers to ensure that a section of an application (known as the "unit") behaves as intended

 

Wikipedia    

test pyramid

Integration tests

End to End tests

Unit tests

Fast

Slow

Dev

Regression

Fast: No side effects, no setup required

Feature code

Tools (IO)

Unit

Test

Unit

Test

Unit

Test

Unit

Test

Unit

Test

Feature code

Dev: They give you a short feedback loop

BENEFITS

ionicons-v5-k

Ease code refactoring

Ease application evolution

Work on small pieces of code

ionicons-v5-i

What does a unit test look like?

Let's create a map

Le sunset

Arrange / Act / Assert

describe('Map', () => {
  it('adds a new marker to the map', () => {
    // Arrange
    const map = new Map(new Name('Anglet'), MarkerList.empty())

    // Act
    map.addMarker('Le Sunset', 23.252353245, 43.5432563457)

    // Assert
    expect(map).toEqual(
      new Map(
        new Name('Anglet'), 
        [new Marker('Le Sunset', 23.252353245, 43.5432563457)]
      )
    )
  })
})

Simple, right? but Why do

we struggle to write

our first tests?

Mistake #1


Coupling everything

Coupling Is your worst enemy


class MyClass {

  myMethod() {
    const repository = PosgresqlRepository.getInstance()
    
    repository.save(new MyObject())
  }
}

Do not use Singleton


class MyClass {

  constructor(private repository: PosgresqlRepository) {}

  myMethod() {    
    this.repository.save(new MyObject())
  }
}

Do Not depend on concrete implementation

Inversion of control

to the rescue

Concrete

class

Concrete

class

Abstraction

Inversion of control

explained

depends on

Mistake #2

 

 testing code that

uses IO

Do not unit test code that uses IO

Input / Ouput

Code

class AddMarkerToMap {
  execute(marker) {
    const repository = PosgresqlMaps.getInstance()
    
    const map = repository.get(marker.mapId)
    
    map.addMarker(
      marker.name, 
      marker.longitude, 
      marker.latitude, 
    )
    
    repository.save(map)
  }
}

This class must not depend on the database

interface Maps {
  get(mapId: MapId): Map
  save(map: Map)
}

// Test
class InMemoryMaps implements Maps {
  // Keep map objects in memory 
}

// Production
class PosgresqlMaps implements Maps {
  // Use Posgresql connection
}

Classes should depend on abstraction

class AddMarkerToMap {
  constructor(private maps: Maps) {}
  
  execute(marker) {
    const map = this.maps.get(marker.mapId)
    
    map.addMarker(
      marker.name, marker.latitude, marker.longitude
    )
    
    this.maps.save(map)
  }
}

The Class does not use IO anymore

it('adds a new marker to the map', () => {
  const maps = InMemoryMaps()
  
  new AddMarkerToMap(maps).execute({
    mapId: 'mapId', name: 'Le Sunset', 
    latitude: 23.252353245, longitude: 43.5432563457
  })

  expect(maps.get('mapId')).toEqual(
    new Map(
      new Marker('Le Sunset', 23.252353245, 43.5432563457)
    )
  )
})

Now, we can test this class

Hexagonal architecture will help you isolate IO

Domain (no IO)

Infrastructure (IO)

User

interface

Adapter

Port

Port

Adapter

Port

ionicons-v5-j

Web

Port

CLI

Domain (no IO)

User interface

Primary port

ionicons-v5-j

Port

Web

CLI

Infrastructure (IO)

Secondary port

Adapter

Port

Adapter

Domain (no IO)

Infrastructure (IO)

Maps

PosgresqlMaps

InMemoryMaps

Use integration tests to ensure your tools work as expected

Hexagonal architecture

schéma port adapter + montrer comment on teste ça après

Domain (no IO)

Unit tests

Infrastructure (IO)

Integration tests

Concrete

class

Interface

Interface

Concrete

class

Tests with IO

vs

tests without IO

Mistake #3

 

 testing private members

Only Test your public methods

Public method

Private method

Private method

Public method

Public method

Class

Test

It's hard to test objects that do too many things

Public method

Public method

Class

Private method

Private method

Private method

Private method

Private method

Apply SRP if your objects do too many things

Work on small things

Class

A

B

C

F

E

D

Test

Test

Test

Test

Test

Test

Test

Test

Test

Test

Test

Test

B

A

C

D

E

F

apply composition

A

B

C

F

E

D

Mistake #4

 

 relying on

Unpredictable code

class Account {
  constructor(private username: string, private password: string) {
    this.hash = bcrypt.hashSync(password, 10)
  }
}

class RegisterCartographer {
  constructor(private accounts: Accounts) {}
  
  execute(account) {    
    this.accounts.save(
      new Account(account.username, account.password)
    )
  }
}

Do not deal with randomness

it('creates a cartographer account', () => {
  const accounts = InMemoryAccounts()
  
  new RegisterCartographer(accounts).execute({
    usename: 'Pepito', password: 'password1'
  })

  // This test fails!
  expect(accounts.getByUsername('Pepito')).toEqual(
    new Account('Pepito', 'password1')
  )
})

Your code must be predictive

interface PasswordEncryptor {
  hash(password: string): string
}

// Test
class InMemoryPasswordEncryptor implements PasswordEncryptor {
  hash(password: string): string {
    return '$2y$10$JqfiXNdcuWErfiy5pAJ4O.wK(...)';
  }
}

// Production
class BcryptPasswordEncryptor implements PasswordEncryptor {
  hash(password: string): string {
    return bcrypt.hashSync(password, 10);
  }
}

Create an abstraction

class Account {
  constructor(
    private username: string, private hashedPassword: string
  ) {}
}

class RegisterCartographer {
  constructor(
    private accounts: Accounts,
    private passwordEncryptor: PasswordEncryptor
  )
  
  execute(account) {
    this.accounts.save(new Account(
      account.username, 
      this.passwordEncryptor.hash(account.password)
    ))
  }
}

Use the new abstraction

class AddMarkerToMap {
  constructor(private maps: Maps) {}
  
  execute(marker) {
    const map = this.maps.get(marker.mapId)
    
    map.addMarker(
      marker.name, marker.latitude, marker.longitude, 
      new Date()
    )
    
    this.maps.save(map)
  }
}

Avoid actual datetimes

Your code must be predictive

it('adds a new marker to the map', () => {
  const maps = InMemoryMaps()
  
  new AddMarkerToMap(maps).execute({
    mapId: 'mapId', name: 'Le Sunset', 
    latitude: 23.252353245, longitude: 43.5432563457
  })

  // This test fails!
  expect(maps.get('mapId')).toEqual(
    new Map(new Marker(
      'Le Sunset', 23.252353245, 43.5432563457, new Date()
    ))
  )
})
interface Clock {
  now(): Date
}

// Test
class InMemoryClock implements Clock {
  now(): Date {
    return new Date('2022-04-13')
  }
}

// Production
class SystemClock implements Clock {
  now(): Date {
    return new Date()
  }
}

Create an abstraction

class AddMarkerToMap {
  constructor(private clock: Clock) {}
  
  execute(marker): void {
    // ...
    map.addMarker(
      marker.name, 
      marker.longitude, 
      marker.latitude, 
      this.clock.now()
    )
    // ...
  }
}

Use the new abstraction

Mistake #5

 

breaking encapsulation to test

What is

encapsulation?

Public method

Public method

Class

Public method

Constructor

Internal

State

Getters

findClosestMarker

getMarkers

Getters are often a

bad idea


it('rename a map', () => {
  const map = new Map(
    new Name('Anglette city'), MarkerList.empty()
  )

  map.rename('Anglet city');

  expect(map.getName()).toEqual(new Name('Anglet city'))
})

it('renames a map', () => {
  const map = new Map(
    new Name('Anglette city'), MarkerList.empty()
  )

  map.rename('Anglet city');

  expect(map).toEqual(
    new Map(new Name('Anglet city'), MarkerList.empty())
  )
  // OR
  expect(
    map.equals(
      new Map(new Name('Anglet city'), MarkerList.empty())
  )).toBe(true)
})

Compare object instances instead

Mistake #6

 

Having understandable tests

describe('Map', () => {
  // Which business rule do you want to describe?
  it('should not add a marker', () => {
    // ...
  })

  // this test case is much more clearer
  it('should not add a marker if a marker has the same location', () => {
      // ...
  })
})

Make Test cases readable for your colleagues

Test cases should NOT Describe implementation detail


describe('Map', () => {
  // What happens if the method signature change? 
  it('returns the Location type of the closest marker', () => {
    // ...
  })
  
  // What happens if the method implementation change?
  it('calls the the method Repository.save method', () => {
    // ...
  })
})

Tests are free documentation

ionicons-v5-k
it('renames a map', () => {
  const map = new Map(
    new MapId('e9a01a8a-9d40-476e-a946-06b159cd484a'),
    new Cartographer('Pepito'),
    new Name('Anglette city'),
    new Description('Good places in Anglet'),
    MarkerList.empty(),
  );

  map.rename('Anglet city');

  expect(map).toEqual(new Map(
    new MapId('e9a01a8a-9d40-476e-a946-06b159cd484a'),
    new Cartographer('Pepito'),
    new Name('Anglet city'),
    new Description('Good places in Anglet'),
    MarkerList.empty(),
  ))
})

Avoid noise to focus on the right things

class Map {
  static whatever(map: Partial<{
    mapId: string,
    addedBy: string,
    name: string,
    description: string,
    markers: Marker[],
  }>): Map {
    return new Map(
      new MapId(map.mapId ?? 'e9a01a8a-9d40-476e-a946-06b159cd484a'),
      new Cartographer(map.addedBy ?? 'Pepito'),
      new Name(map.name ?? 'Anglet city'),
      new Description(map.description ?? 'Good places in Anglet'),
      new MarkerList(map.markers ?? []),
    )
  }
}

Build objects

thanks to Factories


it('renames a map', () => {
  const map = Map.whatever({name: 'Anglette city'})

  map.rename('Anglet city');

  expect(map).toEqual(Map.whatever({name: 'Anglet city'}))
})

We only need a map with

a given name

class Map {
  /** @internal use for testing purpose */
  static whatever(map: Partial<{
    mapId: string,
    addedBy: string,
    name: string,
    description: string,
    markers: Marker[],
  }>): Map {
    return new Map(
      // ...
    )
  }
}

this factory should be only used for test purposes or...

class aMap
{
  private mapId: string  = 'e9a01a8a-9d40-476e-a946-06b159cd484a'
  private name: string  = 'Anglet city'
  // initialize other properties

  named(name: string): MapBuilder {
    this.name = name
    return this
  }
  // ... other setters ....

  build(): Map {
    return new Map(
      new MapId(this.mapId), new MapName(this.name), /** etc... */
    )
  }
}

Build objects

thanks to Builders

it('renames a map', () => {
  const map = new aMap()
    .named('Anglette city')
    .build();

  map.rename('Anglet city');

  expect(map).toEqual(
    new aMap()
      .named('Anglet city')
      .build()
  )
})

We only need a map with

a given name

Objects are instantiated from a single place

ionicons-v5-k

Mistake #7

 

not Taking care of test

code as much as

production code!

Bad test pyramid

End to End tests

Unit tests

Fast

Slow

Take time to refactor your tests code

ionicons-v5-k

Make your tests clear and understandable

Make your tests easy to write (builders, helpers)

ionicons-v5-k

Do not hesitate to remove unrelevant tests

ionicons-v5-k

Mistake #8

 

Not Making your tests

fail once!


it('finds the latest added marker', () => {
  const marker = Marker.whatever({
    makerId: 'a7bda4a5-8f79-41d6-92e6-f0bb2197d086'
  })
  
  const map = Map.whatever({markers: [marker]})

  expect(map.latestAddedMarker()).toEqual(marker)
})

Ensure your tests fail for the right reason

What could be the

next step?

Test Driven Development

Red

Green

Refactor

Check my Blog posts about testing

Thank you!

Any Questions?

@arnolanglade