Skip to content

Commit

Permalink
Merge pull request #15 from miraisolutions/feature/#10-extended-unit-…
Browse files Browse the repository at this point in the history
…tests

Extended unit tests (#10)
  • Loading branch information
spoltier authored Feb 13, 2020
2 parents cc6a8a0 + 7416842 commit a76d76f
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 11 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Each section below mentions typical tools and utilities in a natural order of de
2. [Testing](#testing)
a. [PyCharm file types](#pycharm-file-types)
b. [Type hints](#type-hints)
c. [Property testing](#property-testing)
d. [Mocks in unit tests](#mocks-in-unit-tests)
3. [Documentation](#documentation)
4. [Usage / Jupyter notebook](#usage)
5. [Continuous Integration](#continuous-integration)
Expand Down Expand Up @@ -189,6 +191,32 @@ mypy ./secretsanta/main/core.py
```
to test if the type hints of `.py` file(s) are correct (in which case there may be no output).

#### Property testing

We use [Hypothesis](https://hypothesis.readthedocs.io/en/latest/) to define a _property test_ for our matching function:
generated example inputs are tested against desired properties. Hypothesis' generator can be configured to produce typical
data structures, filled with various instances of primitive types. This is done by composing specific annotations.
* The decorator `@given(...)` must be present before the test function that shall use generated input.
* Generated arguments are defined in a comma-separated list, and will be passed to the test function in order:
```python
from hypothesis import given
from hypothesis.strategies import text, integers


@given(text(), integers())
def test_some_thing(a_string, an_int):
return

```
* Generation can be controlled by various optional parameters, e.g. `text(min_size=2)` for testing with strings that
have at least 2 characters.

#### Mocks in unit tests

Mock objects are used to avoid external side effects. We use the standard Python package `unittest.mock`. This provides
a `@patch` decorator, which allows us to specify classes to be mocked within the scope of a given test case. See
*test_funs.py* and *test_core.py* for examples.

### Documentation
Documentation is done using [Sphinx](http://www.sphinx-doc.org/en/master/usage/quickstart.html).

Expand Down
3 changes: 2 additions & 1 deletion requirements.in
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
-r ./requirements-package.in

flake8
hypothesis
jupyter
pip-tools
pytest
pytest-cov
Sphinx
tox
twine
twine
27 changes: 18 additions & 9 deletions secretsanta/main/funs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import datetime
import smtplib
from contextlib import suppress
from typing import Optional, Dict, Tuple
from typing import Optional, Dict, Tuple, List, Union, Any

import numpy as np

Expand Down Expand Up @@ -44,7 +44,7 @@ def make_santa_dict(dictionary: Dict[str, str], seed: Optional[int] = None, verb
names = [*dictionary.keys()]

# To store the shuffled dictionary, we initialize a new variable
senddict = {}
senddict: Dict[Union[str, Any], Union[str, Any]] = {}
# To avoid the last assignee be one's own secret santa, we may need to swap the two last entries; therefore we need
# to keep track of the second to last one. This variable must be defined here to avoid warnings about undefined
# names.
Expand All @@ -68,8 +68,10 @@ def make_santa_dict(dictionary: Dict[str, str], seed: Optional[int] = None, verb
if picked == name:
swapname2 = picked
tmp = senddict[swapname1]
senddict[swapname1] = senddict[swapname2]
senddict[swapname1] = dictionary[picked]
senddict[swapname2] = tmp
else:
senddict[name] = dictionary[picked]
else:
# if only 2 choices are left we keep track of the name, in case the last e-mail left is the last
# participant's
Expand All @@ -85,17 +87,24 @@ def make_santa_dict(dictionary: Dict[str, str], seed: Optional[int] = None, verb
pick.remove(name)
# randomly pick a participant
picked = np.random.choice(pick, 1)[0]
if verbose:
print(picked)
# set `name`'s value in the result to the picked participant's e-mail.
senddict[name] = dictionary[picked]
names.remove(picked)
if verbose:
print(picked)
# set `name`'s value in the result to the picked participant's e-mail.
senddict[name] = dictionary[picked]
names.remove(picked)

return senddict


def send_santa_dict(smtpserverwithport: str, sender: str, pwd: str,
senddict: Dict[str, str], test: bool = False) -> Dict[str, Tuple[int, bytes]]:
def santa_builder(email: Union[str, List[str]], person: str):
return SecretSanta(email, person)
return internal_send_santa_dict(smtpserverwithport, sender, pwd, senddict, santa_builder, test)


def internal_send_santa_dict(smtpserverwithport: str, sender: str, pwd: str, senddict: Dict[str, str], santabuilder,
test: bool = False) -> Dict[str, Tuple[int, bytes]]:
# "\" is used in the docstring to escape the line ending in sphinx output
"""
loops over a 'santa' dictionary and sends respective emails
Expand Down Expand Up @@ -128,7 +137,7 @@ def parameterized_send(santa: SecretSanta) -> Dict[str, Tuple[int, bytes]]:
# ... we initialize a SecretSanta instance, and call send.
# We capture the results as individual variables from each call's result Dict,
# so we can construct a single Dict containing all failed attempts.
for (email, error) in parameterized_send(SecretSanta(mail, name)).items()
for (email, error) in parameterized_send(santabuilder(mail, name)).items()
}

server.quit()
Expand Down
38 changes: 37 additions & 1 deletion tests/main/test_core.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,49 @@
from unittest import TestCase
from unittest.mock import patch

from secretsanta.main.core import SecretSanta


class InitializeSecretSanta(TestCase):
class TestSecretSanta(TestCase):
def test_secret_santa_init___result_has_helper_rudolph(self):
res = SecretSanta('[email protected]', 'you')
self.assertEqual('rudolph', res.helper)

def test_secret_santa_init___email_attribute_is_string(self):
res = SecretSanta('[email protected]', 'you')
self.assertTrue(isinstance(res.email, str))

@patch('smtplib.SMTP')
def test_secret_santa_send(self, mock_smtp):
"""
Test that the send implementation sends a message containing the specified variable content and metadata
"""
santa_email = '[email protected]'
res = SecretSanta(santa_email, 'you')
from_address = "[email protected]"
subject = "Santa Unit Test"
message = "It's a unit test"
res.send(subject, from_address, message, mock_smtp, test=True)
# use a custom validator (we only care that the message sent to SMTP contains our parameters)
mock_smtp.sendmail.assert_called_with(from_address, santa_email,
SantaMockMessageValidator("Santa", subject,
santa_email, message))


class SantaMockMessageValidator(object):
"""
Mock object validator: overrides equality operator to control match conditions inside `assert_called_with`,
using the provided parameters to check that the produced string (`other`) contains the expected values.
"""
def __init__(self, sender: str, subject: str, santa_email: str, message: str):
self.sender = sender
self.subject = subject
self.santa_email = santa_email
self.message = message

def __eq__(self, other: str):
needed_elements = [self.sender, self.subject, self.santa_email, self.message]
test = all(fragment in other for fragment in needed_elements)
if not test:
print("one of %s is missing in the produced SMTP message!" % needed_elements)
return test
46 changes: 46 additions & 0 deletions tests/main/test_funs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import unittest
from typing import Union, List
from unittest.mock import patch
from hypothesis import given
from secretsanta.main import funs
from hypothesis.strategies import text, lists, integers, characters


class MakeSantaDict(unittest.TestCase):
# we specify some character classes to avoid unprintable characters that cause issues when used as dictionary keys.
# the min / max parameters passed to integers match the accepted range for seeds.
@given(lists(text(alphabet=characters(whitelist_categories=["Lu", "Ll", "Nd", "Pc", "Pd"],
whitelist_characters=["@"]), min_size=2, max_size=20), 2, 10, unique=True),
integers(min_value=0, max_value=2**32 - 1))
def test_all_different_assign(self, test_list, seed):
"""
Test that a generated list of unique names, turned into a dictionary, are all assigned to one another, without
self-assignment.
:param test_list: list of names
:param seed: seed for random choice picking
"""
test_dict = dict(zip(test_list, test_list))
assignment = funs.make_santa_dict(test_dict, seed)
assert len(assignment) == len(test_list)
for left, right in assignment.items():
assert (left != right)

# We don't want to actually send e-mail, and we are testing the send_santa_dict functionality (not the SecretSanta
# class internals)
@patch('secretsanta.main.core.SecretSanta')
@patch('smtplib.SMTP')
def test_send_santa_dict(self, mock_smtp, mock_santa):
"""
Test that the function calls our email sending logic with the expected parameters, the expected number of times
"""
test_dict = dict(zip(["a", "b", "c"], ["[email protected]", "[email protected]", "[email protected]"]))

smtpserverwithport = "lalaland:1337"

def mocksantabuilder(email: Union[str, List[str]], person: str):
return mock_santa(email, person)

funs.internal_send_santa_dict(smtpserverwithport, "mr.jack", "NoOneCanGuess1234", test_dict, mocksantabuilder)
mock_smtp.assert_called_with(smtpserverwithport)
mock_santa.assert_called_with("[email protected]", "c")
assert mock_santa.call_count == 3

0 comments on commit a76d76f

Please sign in to comment.