kaste / mockito-python

Mockito is a spying framework
MIT License
123 stars 12 forks source link

Feature request: Mock current date/time #81

Open eviljoe opened 4 months ago

eviljoe commented 4 months ago

Add support for mocking the value returned from functions like datetime.datetime.now(). I have several functions that make use of the current date. I have not found a way to appropriately test those using Mockito.

The example below is how I have tried to mock the current date/time. I am not asking date/time freezing to work exactly like this example. This is just to help show what I am talking about.

Example

requirements.txt

expects==0.9.0
mamba==0.11.2
mockito==1.4.0

datetime_spec.py

from datetime import datetime

from expects import equal, expect
from mamba import description, before, after, it
from mockito import unstub, when

with description('current date') as self:
    with before.each:
        self.now = datetime(year=2024, month=1, day=1)
        when(datetime).now(...).thenReturn(self.now)

    with after.each:
        unstub()  # <-- This line throws an error

    with it('can mock the date'):
        expect(datetime.now().timestamp()).to(equal(1704067200.0))

Command

python -m mamba.cli ./datetime_spec.py

Error Message

Failure/Error: ./datetime_spec.py unstub()
         TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'
kaste commented 4 months ago

Ah, that won't work because we cannot replace/patch these built-ins when they're read-only. What you do typically is to replace the datetime object with a "spy".

Basically, as you can't patch datetime.now() because datetime is immutable, you replace datetime with a spy(datetime) which you can patch however you want. (And the spy behaves like the original object for all methods you did not mockey-patch). It may be also possible to inject datetime to the functions for easier testing.

Unfortunately I'm not familiar with the test framework/layout you showed here in datetime_spec.py so I really don't know what's going on there. IMO it should have thrown directly on the when(datetime).now()-line.

eviljoe commented 4 months ago

Thanks for the answer. I was looking for a way to accomplish this without having to modify the function I was unit testing to accept an injected datetime module. After looking around a bit more, I am going to try to use freezegun. When using that, the above example would look like this:

requirements.txt

expects==0.9.0
mamba==0.11.2
mockito==1.4.0
freezegun==1.5.0

datetime_spec.py

from datetime import datetime

from expects import equal, expect
from freezegun import freeze_time
from mamba import description, before, after, it
from mockito import unstub, when

with description('current date') as self:
    with it('can mock the date'):
        with freeze_time('2024-01-01T00:00.0+00:00'):
            expect(datetime.now().timestamp()).to(equal(1704067200.0))
kaste commented 4 months ago

Injecting was only one variant - basically the old school variant. You ignored the first one, instead of patching now() you basically patch datetime(), e.g. using spy.

The code you provided as the example is self-contained, and that makes everything a bit harder as you would need to patch the module your looking at, the test module, also you don't have any functions as the unit under test. That makes it harder.

Here is a sketch of how it should work:

# module_under_test.py
from datetime import datetime

def what_time_is_it():
    return datetime.now()

# test.py
from datetime import datetime
import module_under_test

with description('current date') as self:
    with before.each:
        fake_datetime = spy(datetime)
        when(fake_datetime).now().thenReturn("NOW")
        module_under_test.datetime = fake_datetime  # <== you patch the module

    with after.each:
        module_under_test.datetime = datetime

    with it('can mock the date'):
        assert module_under_test.what_time_is_it() == "NOW"

But if you only use now from datetime, you don't need the spy:

with description('current date') as self:
    with before.each:
        module_under_test.datetime = mock({"now": lambda: "NOW"})

    with after.each:
        module_under_test.datetime = datetime

    with it('can mock the date'):
        assert module_under_test.what_time_is_it() == "NOW"

You could also imagine that you deep import now() in module_under_test. Then I think it would go in a different direction; more like:

# module_under_test.py
from datetime.datetime import now

def what_time_is_it():
    return now()

# test.py
from datetime import datetime
import module_under_test

with description('current date') as self:
    with before.each:
        when(module_under_test).now().thenReturn("NOW")

    with after.each:
        unstub()

    with it('can mock the date'):
        assert module_under_test.what_time_is_it() == "NOW"

So there is some design space before choosing the freezegun.

eviljoe commented 4 months ago

Interesting. Thank you for taking the time to make these examples! I was doing what you were doing in the third example previous to this. But, I didn't like having that extra function that just returned the date. I never though of doing it the way you are in the first example, though.