doublex

Python test doubles framework

@david_vi11a

what are doubles?

Test doubles are atrezzo objects.

why should we use doubles?

Unit tests must isolate the SUT.

So all collaborators should be replaced.

They require (and promote) your classes meets DIP and LSP.

then, instead of production class instances...

 collaborator = Collaborator()

 sut = SUT(collaborator)

 sut.exercise()
	    
...give it doubles

 double = Double(Collaborator)

 sut = SUT(double)

 sut.exercise()
	    

which is the right double?

  • Dummy: a placeholder object, never invoked
  • Fake: Q&D replacement, not suitable for production
  • Stub: returns hardcoded responses: It say you want to hear
  • Spy: Stub that records received invocations
  • Mock: holds and check programmed expectations

[ according to xunitpatterns.com/Test Double.html ]

EXAMPLE
account service

  class AccountService:
      def __init__(self, store, password_service):
	  [...]

      def create_user(self, login):
          [...]
          - raise InvalidPassword()
          - raise AlreadyExists()

      def create_group(self, group_name, user_names):
          [...]
	    

[ slides related to that example
have gray background ]

account service

collaborators


  class AccountStore:
      def save(self, login, password):
          [...]

      def has_user(self, login):
          [...]

  class PasswordService:
      def generate(self):
          [...]
	    

Stub

In a free stub, any method may be invoked:


  class AccountTests(TestCase):
      def test_account_creation(self):
          with Stub() as password_service:
              password_service.generate().returns('secret')

          service = AccountService(store=Stub(), password_service)

          service.create_user('John')
	    

Stub

... you can set return value depending on arguments


 with Stub() as stub:
     stub.foo(2, 2).returns(100)
     stub.foo(3, ANY_ARG).returns(200)

 assert_that(stub.foo(1, 1), is_(None))
 assert_that(stub.foo(2, 2), is_(100))
 assert_that(stub.foo(3, 0), is_(200))
	    

Stub

... or by hamcrest matcher


 with Stub() as stub:
     stub.foo(2, greater_than(4)).returns(100)

 assert_that(stub.foo(2, 1), is_(None))
 assert_that(stub.foo(2, 5), is_(100))
	    

Stub

... or by composite hamcrest matcher


 with Stub() as stub:
     stub.foo(2, has_length(all_of(
         greater_than(4), less_than(8)))).returns(1000)

 assert_that(stub.foo(2, "bad"), is_(None))
 assert_that(stub.foo(2, "enough"), is_(1000))
	    

Stub

interface may be restricted to a given class:


 with Stub(PasswordService) as password_service:
     password_service.generate().returns('secret')

 stub.generate()
 stub.generate(9)
TypeError: PasswordService.generate() takes exactly 1 argument (2 given)

 stub.wrong()
AttributeError: 'PasswordService' object has no attribute 'wrong'
	    

in our AccountService test:


  class AccountTests(TestCase):
      def test_account_creation(self):
          with Stub(PasswordService) as password_service:
              password_service.generate().returns('secret')

          service = AccountService(store=Stub(), password_service)

          service.create_user('John')
    

... is 'store' really called??

we need a spy

Spy

checking double invocations: called()


 store = Spy(AccountStore)
 service = AccountService(store, password_service)

 service.create_group('team', ['John', 'Peter', 'Alice'])

 assert_that(store.save, called())
    

but... is really called three times?

Spy

checking called times: times()

(also with matchers)


 store = Spy(AccountStore)
 service = AccountService(store, password_service)

 service.create_group('team', ['John', 'Peter', 'Alice'])

 assert_that(store.save, called().times(3))
 assert_that(store.save, called().times(greater_than(2)))
    

but... is really called with the right arguments?

Spy

check argument values: with_args()

(also with matchers)


 store = Spy(AccountStore)
 service = AccountService(store, password_service)

 service.create_user('John')

 assert_that(store.save, called().with_args('John', 'secret'))
 assert_that(store.save, called().with_args('John', ANY_ARG))
 assert_that(store.save,
             called().with_args(contains_string('oh'), ANY_ARG))
 assert_that(store.save,
             never(called().with_args('Alice', anything())))
    

Spy

check keyword argument

(also with matcher)


 spy = Spy()
 spy.foo(name="Mary")

 assert_that(spy.foo,
             called().with_args(name="Mary"))

 assert_that(spy.foo,
             called().with_args(name=contains_string("ar")))
    

Spy

meaning-full report messages!


 service.create_group('team', ['John', 'Alice'])

 assert_that(store.save, called().with_args('Peter'))
AssertionError:
Expected: these calls:
          AccountStore.save('Peter')
     but: calls that actually ocurred were:
          AccountStore.has_user('John')
          AccountStore.save('John', 'secret')
          AccountStore.has_user('Alice')
          AccountStore.save('Alice', 'secret')
    

ProxySpy

propagates invocations to the collaborator


 with ProxySpy(AccountStore()) as store:
     store.has_user('John').returns(True)

 service = AccountService(store, password_service)

 with self.assertRaises(AlreadyExists):
     service.create_user('John')
    

CAUTION: ProxySpy is not a true double,
this invokes the actual AccountStore instance!

Mock

programming expectations


        with Mock(AccountStore) as store:
            store.has_user('John')
            store.save('John', anything())
            store.has_user('Peter')
            store.save('Peter', anything())

        service = AccountService(store, password_service)

	service.create_group('team', ['John', 'Peter'])

        assert_that(store, verify())
    

Mock assures these invocations (and only these) are ocurred.

ah hoc stub methods


 collaborator = Collaborator()
 collaborator.foo = method_returning('bye')
 assert_that(self.collaborator.foo(), is_('bye'))

 collaborator.foo = method_raising(SomeException)
 collaborator.foo()
SomeException:
  

stub observers

attach additional behavior


 class Observer(object):
     def __init__(self):
         self.state = None

     def update(self, *args, **kargs):
         self.state = args[0]

 observer = Observer()
 stub = Stub()
 stub.foo.attach(observer.update)
 stub.foo(2)

 assert_that(observer.state, is_(2))
  

stub delegates

delegating to callables


 def get_pass():
     return "12345"

 with Stub(PasswordService) as password_service:
     password_service.generate().delegates(get_pass)

 store = Spy(AccountStore)
 service = AccountService(store, password_service)

 service.create_user('John')

 assert_that(store.save, called().with_args('John', '12345'))
    

stub delegates

delegating to iterables/generators


 with Stub(PasswordService) as password_service:
     password_service.generate().delegates(["12345", "mypass", "nope"])

 store = Spy(AccountStore)
 service = AccountService(store, password_service)

 service.create_group('team', ['John', 'Peter', 'Alice'])

 assert_that(store.save, called().with_args('John', '12345'))
 assert_that(store.save, called().with_args('Peter', 'mypass'))
 assert_that(store.save, called().with_args('Alice', 'nope'))
    

stubbing properties


 class Collaborator(object):
     @property
     def prop(self):
         return 1

     @prop.setter
     def prop(self, value):
         pass

 with Spy(Collaborator) as spy:
     spy.prop = 2

 assert_that(spy.prop, is_(2))  # double property getter invoked
    

spying properties

(also with matcher)


 assert_that(spy, property_got('prop'))

 spy.prop = 4  # double property setter invoked
 spy.prop = 5  # --
 spy.prop = 5  # --

 assert_that(spy, property_set('prop'))  # set to any value
 assert_that(spy, property_set('prop').to(4))
 assert_that(spy, property_set('prop').to(5).times(2))
 assert_that(spy,
             never(property_set('prop').to(greater_than(6))))
    

Questions?

References