DEV Community

Cover image for Practice TDD by creating a LibreLingo-based language-learning app
Daniel Kantor
Daniel Kantor

Posted on

Practice TDD by creating a LibreLingo-based language-learning app

In this tutorial you'll learn how to use LibreLingo course data to create
language-learning apps in Python.

We'll build a simple function that lists the audio files required by
a LibreLingo course.

LibreLingo comes with some tools that make it easier to build such
programs and also to test them. I structured the tutorial as a small
Test-Driven-Development session so that you'll see how to use these tools.

By the end of the tutorial you should have a good basis to build
your own apps, APIs or software using LibreLingo, or to contribute
to LibreLingo or existing LibreLingo-based software.

Requirements

To enjoy this tutorial, you'll need to have some experience with
Python, and you also need to have Python 3.9 installed on your system,
as well as the poetry dependency management tool.

Setting up your project

To set up our project, we're going to first create an empty folder.

If you are creating your app inside the LibreLingo monorepo,
that's how you'd do it:

cd apps/ mkdir librelingo_audios cd librelingo_audios/ 
Enter fullscreen mode Exit fullscreen mode

Let's initialize our project using poetry:

poetry init 
Enter fullscreen mode Exit fullscreen mode

If you want to learn more about poetry, you can check out their
documentation on initializing your project.

Let's create the basic folder structure of our project:

mkdir librelingo_audios mkdir tests touch librelingo_audios/__init__.py 
Enter fullscreen mode Exit fullscreen mode

We want to test our app, so we need to install pytest as a development
dependency of our project:

poetry add --dev pytest 
Enter fullscreen mode Exit fullscreen mode

Let's see if pytest works:

poetry run pytest 
Enter fullscreen mode Exit fullscreen mode

The output is:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios collected 0 items =================================== no tests ran in 0.00s ==================================== 
Enter fullscreen mode Exit fullscreen mode

Looks like pytest works and we can start writing our tests!

Let's create tests/test_list_missing_audios.py with this simple test case:

from librelingo_audios import list_missing_audios def test_returns_hello_world(): assert list_missing_audios() == "Hello World" 
Enter fullscreen mode Exit fullscreen mode

Let's try running this test:

poetry run pytest 
Enter fullscreen mode Exit fullscreen mode

We get this error:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 178 items / 1 error / 177 selected =========================================== ERRORS =========================================== _________ ERROR collecting apps/librelingo_audios/tests/test_list_missing_audios.py __________ ImportError while importing test module '/home/kdani/repos/LibreLingo/apps/librelingo_audios/tests/test_list_missing_audios.py'. Hint: make sure your test modules/packages have valid Python names. Traceback: /usr/lib/python3.9/importlib/__init__.py:127: in import_module return _bootstrap._gcd_import(name[level:], package, level) apps/librelingo_audios/tests/test_list_missing_audios.py:1: in <module>  from librelingo_audios import list_missing_audios E ModuleNotFoundError: No module named 'librelingo_audios' ================================== short test summary info =================================== ERROR apps/librelingo_audios/tests/test_list_missing_audios.py !!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!! ====================================== 1 error in 0.88s ====================================== 
Enter fullscreen mode Exit fullscreen mode

Makes sense, because we haven't created our implementation yet!

Lets create librelingo_audios/list_missing_audios.py with the following content:

def list_missing_audios(): return "Hello World" 
Enter fullscreen mode Exit fullscreen mode

We need to be able to import that in our test, so let's create librelingo_audios/__init__.py:

__version__ = '0.1.0' from librelingo_audios.list_missing_audios import list_missing_audios 
Enter fullscreen mode Exit fullscreen mode

If you did that, this is how your directory structure should look like now:

├── librelingo_audios │   ├── __init__.py │   ├── list_missing_audios.py ├── poetry.lock ├── pyproject.toml └── tests └── test_list_missing_audios.py 
Enter fullscreen mode Exit fullscreen mode

Let's try running our tests again:

poetry run pytest 
Enter fullscreen mode Exit fullscreen mode

You'll get the following output:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios collected 1 item tests/test_list_missing_audios.py . [100%] ===================================== 1 passed in 0.01s ====================================== 
Enter fullscreen mode Exit fullscreen mode

Loading LibreLingo courses

LibreLingo comes with a Python package that facilitates loading courses in
Python programs. Let's install it:

poetry add librelingo_yaml_loader 
Enter fullscreen mode Exit fullscreen mode

Let's play around with this library in ipython to see how it works:

poetry run ipython 
Enter fullscreen mode Exit fullscreen mode
In [1]: import librelingo_yaml_loader In [2]: course = librelingo_yaml_loader.load_course('../../courses/spanish-from-english/') 
Enter fullscreen mode Exit fullscreen mode

We've loaded our course into the variable course.
Now you should be able to access course data.

For example, we can see what the target language of the course is:

In [3]: course.target_language.name Out[3]: 'Spanish' 
Enter fullscreen mode Exit fullscreen mode

We can see what the title of the first Module is:

In [4]: course.modules[0].title Out[4]: 'Basics' 
Enter fullscreen mode Exit fullscreen mode

We can list the Phrase objects included in
the second Skill of the first Module like so:

In [6]: course.modules[0].skills[1].phrases Out[6]: [Phrase(in_target_language=['Buen provecho'], in_source_language=['Enjoy your meal']), Phrase(in_target_language=['Por favor'], in_source_language=['Please']), Phrase(in_target_language=['Pan, por favor'], in_source_language=['Bread, please']), Phrase(in_target_language=['Agua, por favor'], in_source_language=['Water, please']), Phrase(in_target_language=['Cecilia bebe agua'], in_source_language=['Cecilia drinks water']), Phrase(in_target_language=['La pareja bebe cerveza'], in_source_language=['The couple drinks beer']), Phrase(in_target_language=['José come pan'], in_source_language=['José eats bread']), Phrase(in_target_language=['Yo como pasta'], in_source_language=['I eat pasta'])] 
Enter fullscreen mode Exit fullscreen mode

Adding some tests

We'll need some data to write our tests. We could use real data here, but hat
would have some disadvantages:

  • Real courses evolve over time, so they might break our tests in the future
  • A real course can be difficult to navigate due to it's size
  • A real course can take up a lot of memory
  • Loading real courses could slow our tests down

Thankfully, LibreLingo comes with a library that has fake data to simplify
writing tests! Let's install it:

poetry add --dev librelingo-fakes 
Enter fullscreen mode Exit fullscreen mode

Let's remove our existing test:

 def test_returns_hello_world(): assert list_missing_audios() == "Hello World" 
Enter fullscreen mode Exit fullscreen mode

And replace it with a real test that verifies that an empty course doesn't
need any audio files:

from librelingo_fakes import fakes from librelingo_audios.list_missing_audios import list_missing_audios def test_an_empty_course_does_not_have_any_audios(): assert list(list_missing_audios(fakes.courseEmpty)) == [] 
Enter fullscreen mode Exit fullscreen mode

If we run our tests again, we see the following failure:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 1 item tests/test_list_missing_audios.py F [100%] ========================================== FAILURES ========================================== _______________________ test_an_empty_course_does_not_have_any_audios ________________________ def test_an_empty_course_does_not_have_any_audios(): > assert list(list_missing_audios(fakes.courseEmpty)) == [] E TypeError: list_missing_audios() takes 0 positional arguments but 1 was given tests/test_list_missing_audios.py:7: TypeError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_an_empty_course_does_not_have_any_audios - T... ===================================== 1 failed in 0.03s ====================================== 
Enter fullscreen mode Exit fullscreen mode

Let's cheat a little bit to make that test pass:

def list_missing_audios(course): return [] 
Enter fullscreen mode Exit fullscreen mode

This shows us that our "implementation" already works for empty courses, but
that's a rather unrealistic edge-case. So we need to come up with some
more precise test cases to force ourselves to write the correct implementation.

Let's look at the first fake course. By running this code,
we figure out that this course has 2 phrases in total:

In [41]: count = 0 In [42]: for module in fakes.course1.modules: ...: for skill in module.skills: ...: count += len(skill.phrases) ...: In [43]: count Out[43]: 2 
Enter fullscreen mode Exit fullscreen mode

Every phrase has one corresponding audio, so this means our fake course
is going to need 2 audios.

We expect our function to return 2 items:

def test_a_course_with_2_phrases_needs_2_audios(): assert len(list(list_missing_audios(fakes.course1))) == 2 
Enter fullscreen mode Exit fullscreen mode

Let's cheat again to make that test pass:

def list_missing_audios(course): if not course.modules: return [] return ["foo", 42] 
Enter fullscreen mode Exit fullscreen mode

By further exploration, we learn that the second fake course doesn't have
any phrases:

In [41]: count = 0 In [42]: for module in fakes.course2.modules: ...: for skill in module.skills: ...: count += len(skill.phrases) ...: In [43]: count Out[43]: 0 
Enter fullscreen mode Exit fullscreen mode

Lets change the test for the empty course to instead use course2:

def test_a_course_with_0_phrases_needs_zero_audios(): assert len(list(list_missing_audios(fakes.course2))) == 0 
Enter fullscreen mode Exit fullscreen mode

Our tests are failing again:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 2 items tests/test_list_missing_audios.py F. [100%] ========================================== FAILURES ========================================== _______________________ test_a_course_with_0_phrases_needs_zero_audios _______________________ def test_a_course_with_0_phrases_needs_zero_audios(): > assert len(list(list_missing_audios(fakes.course2))) == 0 E AssertionError: assert 2 == 0 E + where 2 = len(['foo', 42]) E + where ['foo', 42] = list(['foo', 42]) E + where ['foo', 42] = list_missing_audios(Course(target_language=Language(name='another language', code='tr'), source_language=Language(name='my language', code...fruit', is_in_target_language=True), DictionaryItem(word='ipsum', definition='red fruit', is_in_target_language=True)])) E + where Course(target_language=Language(name='another language', code='tr'), source_language=Language(name='my language', code...fruit', is_in_target_language=True), DictionaryItem(word='ipsum', definition='red fruit', is_in_target_language=True)]) = fakes.course2 tests/test_list_missing_audios.py:7: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_a_course_with_0_phrases_needs_zero_audios - ... ================================ 1 failed, 1 passed in 0.03s ================================= 
Enter fullscreen mode Exit fullscreen mode

Since course2 has modules (all with no phrases) this time it's not as easy to
cheat with the implementation.

The simplest way to make the test pass is probably actually
implementing the iteration:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: yield None 
Enter fullscreen mode Exit fullscreen mode

Our tests are now passing!

One problem is that although now every phrase is there,
the output format is still useless.

We need to include the text of each phrase in the target language
of the course. Let's make sure it's always the second item in the output:

def test_result_includes_the_phrase_in_the_target_language(): result = list(list_missing_audios(fakes.course1)) # We are using in_target_language[0] because only the first version is used for audios  assert result[0][1] == fakes.course1.modules[0].skills[0].phrases[0].in_target_language[0] 
Enter fullscreen mode Exit fullscreen mode

Let's run our tests again:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 3 items tests/test_list_missing_audios.py ..F [100%] ========================================== FAILURES ========================================== ___________________ test_result_includes_the_phrase_in_the_target_language ___________________ def test_result_includes_the_phrase_in_the_target_language(): result = list(list_missing_audios(fakes.course1)) > assert result[0][1] == fakes.course1.modules[0].skills[0].phrases[0].in_target_language E TypeError: 'NoneType' object is not subscriptable tests/test_list_missing_audios.py:16: TypeError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_result_includes_the_phrase_in_the_target_language ================================ 1 failed, 2 passed in 0.03s ================================= 
Enter fullscreen mode Exit fullscreen mode

Let's try cheating with the implementation:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: yield [None, ""] 
Enter fullscreen mode Exit fullscreen mode

Now we get this error:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 3 items tests/test_list_missing_audios.py ..F [100%] ========================================== FAILURES ========================================== ___________________ test_result_includes_the_phrase_in_the_target_language ___________________ def test_result_includes_the_phrase_in_the_target_language(): result = list(list_missing_audios(fakes.course1)) > assert result[0][1] == fakes.course1.modules[0].skills[0].phrases[0].in_target_language E AssertionError: assert '' == ['lorem ipsum'] E + where ['lorem ipsum'] = Phrase(in_target_language=['lorem ipsum'], in_source_language=['john smith']).in_target_language tests/test_list_missing_audios.py:16: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_result_includes_the_phrase_in_the_target_language ================================ 1 failed, 2 passed in 0.03s ================================= 
Enter fullscreen mode Exit fullscreen mode

We can continue cheating though:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: yield [None, "lorem ipsum"] 
Enter fullscreen mode Exit fullscreen mode

Our tests are now passing again 🎉!

This shows our test wasn't specific enough. Let's add another example:

def test_result_includes_the_phrase_in_the_target_language_2(): result = list(list_missing_audios(fakes.course1)) assert result[1][1] == fakes.course1.modules[0].skills[1].phrases[0].in_target_language[0] 
Enter fullscreen mode Exit fullscreen mode

We get the following failure:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 4 items tests/test_list_missing_audios.py ...F [100%] ========================================== FAILURES ========================================== __________________ test_result_includes_the_phrase_in_the_target_language_2 __________________ def test_result_includes_the_phrase_in_the_target_language_2(): result = list(list_missing_audios(fakes.course1)) > assert result[1][1] == fakes.course1.modules[0].skills[1].phrases[0].in_target_language[0] E AssertionError: assert 'lorem ipsum' == 'foous barus' E - foous barus E + lorem ipsum tests/test_list_missing_audios.py:21: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_result_includes_the_phrase_in_the_target_language_2 ================================ 1 failed, 3 passed in 0.03s ================================= 
Enter fullscreen mode Exit fullscreen mode

This time around writing the actual implementation is easier than
trying to trick the tests:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  yield [None, phrase.in_target_language[0]] 
Enter fullscreen mode Exit fullscreen mode

Let's refactor a bit: we extract the text to a new variable!

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] yield [None, text] 
Enter fullscreen mode Exit fullscreen mode

The reason anyone would call the function list_missing_audios is probably because
they want to download/create those audios somehow.

If they want to create those files, then for practical reasons the filenames
should follow a standardized system that other LibreLingo-related software
can also recognize.

To achieve that, we can use the audio_id function from librelingo-utils.

Let's start simple. First let's make sure that the IDs are string:

def test_audio_id_is_a_string(): assert [type(result[0]) for result in list_missing_audios(fakes.course1)] == [str, str] 
Enter fullscreen mode Exit fullscreen mode
==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, snapshottest-0.6.0 collected 5 items tests/test_list_missing_audios.py ....F [100%] ========================================== FAILURES ========================================== _________________________________ test_audio_id_is_a_string __________________________________ def test_audio_id_is_a_string(): > assert [type(result[0]) for result in list_missing_audios(fakes.course1)] == [str, str] E AssertionError: assert [<class 'None...s 'NoneType'>] == [<class 'str'>, <class 'str'>] E At index 0 diff: <class 'NoneType'> != <class 'str'> E Use -v to get the full diff tests/test_list_missing_audios.py:24: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_audio_id_is_a_string - AssertionError: asser... ================================ 1 failed, 4 passed in 0.04s ================================= 
Enter fullscreen mode Exit fullscreen mode

We can still cheat by returning an empty string:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] yield ["", text] 
Enter fullscreen mode Exit fullscreen mode

And out tests are passing again... We better make sure somehow that
audio_id is actually used to perform the filename-logic.

First let's install librelingo-utils:

poetry add librelingo-utils 
Enter fullscreen mode Exit fullscreen mode

And write a tests that only passes if our function calls audio_id.

To do that, we'll need to install pytest-mock:

poetry add --dev pytest-mock 
Enter fullscreen mode Exit fullscreen mode

We can now write our test:

def test_calls_audio_id_to_get_the_id(mocker): audio_id = mocker.patch('librelingo_audios.list_missing_audios.audio_id') list_missing_audios(fakes.course1) assert audio_id.call_count == 2 
Enter fullscreen mode Exit fullscreen mode

This will mock the audio_id function and see how many times it has been called.
We assert that it has to run twice, since there are 2 phrases that need audio files.

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 6 items tests/test_list_missing_audios.py .....F [100%] ========================================== FAILURES ========================================== _____________________________ test_calls_audio_id_to_get_the_id ______________________________ mocker = <pytest_mock.plugin.MockerFixture object at 0x7ff94ee398e0>  def test_calls_audio_id_to_get_the_id(mocker): > audio_id = mocker.patch('librelingo_audios.list_missing_audios.audio_id')  tests/test_list_missing_audios.py:29: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ /home/kdani/.cache/pypoetry/virtualenvs/librelingo-audios-yD2wurwN-py3.9/lib/python3.9/site-packages/pytest_mock/plugin.py:352: in __call__ return self._start_patch( /home/kdani/.cache/pypoetry/virtualenvs/librelingo-audios-yD2wurwN-py3.9/lib/python3.9/site-packages/pytest_mock/plugin.py:161: in _start_patch  mocked = p.start() # type: unittest.mock.MagicMock /usr/lib/python3.9/unittest/mock.py:1541: in start result = self.__enter__() /usr/lib/python3.9/unittest/mock.py:1405: in __enter__ original, local = self.get_original() _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <unittest.mock._patch object at 0x7ff94ee39a00>  def get_original(self): target = self.getter() name = self.attribute original = DEFAULT local = False try: original = target.__dict__[name] except (AttributeError, KeyError): original = getattr(target, name, DEFAULT) else: local = True if name in _builtins and isinstance(target, ModuleType): self.create = True if not self.create and original is DEFAULT: > raise AttributeError(  "%s does not have the attribute %r" % (target, name) ) E AttributeError: <function list_missing_audios at 0x7ff94ee4c5e0> does not have the attribute 'audio_id'  /usr/lib/python3.9/unittest/mock.py:1378: AttributeError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_calls_audio_id_to_get_the_id - AttributeErro... ================================ 1 failed, 5 passed in 0.13s ================================= 
Enter fullscreen mode Exit fullscreen mode

That's not exactly what I expected. Looks like mocking is problematic in Python when your
file name is the same of a function name, because it will try to mock an attribute of the
function instead of the file.

After some Googling I couldn't find an easy and clean solution for this, so
let's just rename list_missing_audios.py to functions.py to avoid trouble.

I'm planning to put more functions here anyway.

In our test file, let's change the import:

from librelingo_audios.functions import list_missing_audios 
Enter fullscreen mode Exit fullscreen mode

Let's change the mock:

def test_calls_audio_id_to_get_the_id(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') list_missing_audios(fakes.course1) assert audio_id.call_count == 2 
Enter fullscreen mode Exit fullscreen mode

Let's also change the import in __init__.py:

__version__ = '0.1.0' from librelingo_audios.functions import list_missing_audios 
Enter fullscreen mode Exit fullscreen mode

Now if we run our tests, we see the failure that I originally expected, or at least something similar:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 6 items tests/test_list_missing_audios.py .....F [100%] ========================================== FAILURES ========================================== _____________________________ test_calls_audio_id_to_get_the_id ______________________________ mocker = <pytest_mock.plugin.MockerFixture object at 0x7f458c9cc8e0>  def test_calls_audio_id_to_get_the_id(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') list_missing_audios(fakes.course1) > assert audio_id.call_count == 2 E AssertionError: assert 0 == 2 E + where 0 = <MagicMock name='audio_id' id='139936688556448'>.call_count  tests/test_list_missing_audios.py:31: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_calls_audio_id_to_get_the_id - AssertionErro... ================================ 1 failed, 5 passed in 0.04s ================================= 
Enter fullscreen mode Exit fullscreen mode

Whoops, our test forgot to actually iterate over the result, lets wrap the function call in
list() to fix that.

def test_calls_audio_id_to_get_the_id(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') list(list_missing_audios(fakes.course1)) assert audio_id.call_count == 2 
Enter fullscreen mode Exit fullscreen mode

Now let's change the implementation so that it actually calls audio_id:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] audio_id(course.source_language, "lorem ipsum") yield ["", text] 
Enter fullscreen mode Exit fullscreen mode

Our tests are now passing:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 6 items tests/test_list_missing_audios.py ...... [100%] ===================================== 6 passed in 0.02s ====================================== 
Enter fullscreen mode Exit fullscreen mode

Notice though, we are actually passing the source language instead of
the target language! Also we're calling it with an empty string instead of
the actual string:

audio_id(course.source_language, "") 
Enter fullscreen mode Exit fullscreen mode

We need to be a lot stricter in our tests so let's fix that:

def test_calls_audio_id_with_the_correct_arguments(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') list(list_missing_audios(fakes.course1)) expected_call = mocker.call(fakes.course1.target_language, fakes.course1.modules[0].skills[0].phrases[0].in_target_language[0]) audio_id.assert_has_calls([expected_call]) 
Enter fullscreen mode Exit fullscreen mode

That causes our tests to fail so let's update the implementation:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] audio_id(course.target_language, "lorem ipsum") yield ["", text] 
Enter fullscreen mode Exit fullscreen mode
==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 7 items tests/test_list_missing_audios.py ....... [100%] ===================================== 7 passed in 0.03s ====================================== 
Enter fullscreen mode Exit fullscreen mode

But you'll notice this is still cheating, because we're always using
"lorem ipsum" as the text. Let's extend our test case to fix that.

def test_calls_audio_id_with_the_correct_arguments(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') list(list_missing_audios(fakes.course1)) expected_call_1 = mocker.call(fakes.course1.target_language, fakes.course1.modules[0].skills[0].phrases[0].in_target_language[0]) expected_call_2 = mocker.call(fakes.course1.target_language, fakes.course1.modules[0].skills[1].phrases[0].in_target_language[0]) audio_id.assert_has_calls([expected_call_1, expected_call_2]) 
Enter fullscreen mode Exit fullscreen mode

Yup, our tests are failing again!

Let's make them pass:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] audio_id(course.target_language, phrase.in_target_language[0]) yield ["", text] 
Enter fullscreen mode Exit fullscreen mode

This looks good, but we're still not returning the ID!
You guessed it, that's a new test case for us!

We will make our mock function return a fake value and we'll
assert that value shows up in the result.

def test_returns_correct_audio_id(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') audio_id.return_value = "omg" assert list(list_missing_audios(fakes.course1))[0][0] == "omg" 
Enter fullscreen mode Exit fullscreen mode

If we run the tests we get the error that we expected:

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 8 items tests/test_list_missing_audios.py .......F [100%] ========================================== FAILURES ========================================== _______________________________ test_returns_correct_audio_id ________________________________ mocker = <pytest_mock.plugin.MockerFixture object at 0x7fe9191977f0>  def test_returns_correct_audio_id(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') audio_id.return_value = "omg" > assert list(list_missing_audios(fakes.course1))[0][0] == "omg" E AssertionError: assert '' == 'omg' E - omg tests/test_list_missing_audios.py:45: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_returns_correct_audio_id - AssertionError: a... ================================ 1 failed, 7 passed in 0.04s ================================= 
Enter fullscreen mode Exit fullscreen mode

Let's cheat again to make the test pass:

def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] audio_id(course.target_language, phrase.in_target_language[0]) yield ["omg", text] 
Enter fullscreen mode Exit fullscreen mode

Our tests now pass, but we are still returning the wrong output.

Let's make another test case to make it impossible to cheat.

Since the return value of the mock function is entirely determined by the fake
value that we supply in our test case, this is a value the implementation can
only reproduce by actually calling the function and taking the return value.

The only one way our implementation can cheat is if the return value is
one simple static example, which is exactly what we have right now.

The most straightforward way to avoid that is by adding an identical test case
with a different mock value:

def test_returns_correct_audio_id_2(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') audio_id.return_value = "foobar" assert list(list_missing_audios(fakes.course1))[0][0] == "foobar" 
Enter fullscreen mode Exit fullscreen mode
==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 9 items tests/test_list_missing_audios.py ........F [100%] ========================================== FAILURES ========================================== ______________________________ test_returns_correct_audio_id_2 _______________________________ mocker = <pytest_mock.plugin.MockerFixture object at 0x7f83d4646a00>  def test_returns_correct_audio_id_2(mocker): audio_id = mocker.patch('librelingo_audios.functions.audio_id') audio_id.return_value = "foobar" > assert list(list_missing_audios(fakes.course1))[0][0] == "foobar" E AssertionError: assert 'omg' == 'foobar' E - foobar E + omg tests/test_list_missing_audios.py:51: AssertionError ================================== short test summary info =================================== FAILED tests/test_list_missing_audios.py::test_returns_correct_audio_id_2 - AssertionError:... ================================ 1 failed, 8 passed in 0.05s ================================= 
Enter fullscreen mode Exit fullscreen mode

Let's fix our implementation:

from librelingo_utils import audio_id def list_missing_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] id_ = audio_id(course.target_language, phrase.in_target_language[0]) yield [id_, text] 
Enter fullscreen mode Exit fullscreen mode

Now let's do a little bit of refactoring. I don't want to shove more functionality
into this function, so let's rename it to list_required_audios across all files.

Later I can create a list_missing_audios which will actually filter out all
the audio files that already exist.

from librelingo_utils import audio_id def list_required_audios(course): for module in course.modules: for skill in module.skills: for phrase in skill.phrases: # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] id_ = audio_id(course.target_language, phrase.in_target_language[0]) yield [id_, text] 
Enter fullscreen mode Exit fullscreen mode

Just checking that the tests still pass...

==================================== test session starts ===================================== platform linux -- Python 3.9.1, pytest-6.2.2, py-1.10.0, pluggy-0.13.1 rootdir: /home/kdani/repos/LibreLingo/apps/librelingo_audios plugins: pyfakefs-4.4.0, mock-3.5.1, snapshottest-0.6.0 collected 9 items tests/test_list_missing_audios.py ......... [100%] ===================================== 9 passed in 0.03s ====================================== 
Enter fullscreen mode Exit fullscreen mode

Now let's extract the iteration part to simplify our function:

from librelingo_utils import audio_id def _iterate_phrases(course): '"Flatten" a course into a sequence of phrases' for module in course.modules: for skill in module.skills: for phrase in skill.phrases: yield phrase def list_required_audios(course): for phrase in _iterate_phrases(course): # Returning only the first version because  # the other versions never need audio.  text = phrase.in_target_language[0] id_ = audio_id(course.target_language, phrase.in_target_language[0]) yield [id_, text] 
Enter fullscreen mode Exit fullscreen mode

That concludes this tutorial! If you are interested in learning more about
how LibreLingo works,
check out our source code on GitHub,
our development documentation or
join our chat.

Top comments (0)