|  | 
|  | 1 | +# tests/test_api.py | 
|  | 2 | +import re | 
|  | 3 | +from unittest.mock import patch, mock_open, call | 
|  | 4 | + | 
|  | 5 | +import pytest | 
|  | 6 | + | 
|  | 7 | +from ch10.api import is_valid, export, write_csv | 
|  | 8 | + | 
|  | 9 | + | 
|  | 10 | +@pytest.fixture | 
|  | 11 | +def min_user(): | 
|  | 12 | +    """Represent a valid user with minimal data. """ | 
|  | 13 | +    return { | 
|  | 14 | + | 
|  | 15 | +        'name': 'Primus Minimus', | 
|  | 16 | +        'age': 18, | 
|  | 17 | +    } | 
|  | 18 | + | 
|  | 19 | + | 
|  | 20 | +@pytest.fixture | 
|  | 21 | +def full_user(): | 
|  | 22 | +    """Represent valid user with full data. """ | 
|  | 23 | +    return { | 
|  | 24 | + | 
|  | 25 | +        'name': 'Maximus Plenus', | 
|  | 26 | +        'age': 65, | 
|  | 27 | +        'role': 'emperor', | 
|  | 28 | +    } | 
|  | 29 | + | 
|  | 30 | + | 
|  | 31 | +@pytest.fixture | 
|  | 32 | +def users(min_user, full_user): | 
|  | 33 | +    """List of users, two valid and one invalid. """ | 
|  | 34 | +    bad_user = { | 
|  | 35 | + | 
|  | 36 | +        'name': 'Horribilis', | 
|  | 37 | +    } | 
|  | 38 | +    return [min_user, bad_user, full_user] | 
|  | 39 | + | 
|  | 40 | + | 
|  | 41 | +class TestIsValid: | 
|  | 42 | +    """Test how code verifies whether a user is valid or not. """ | 
|  | 43 | + | 
|  | 44 | +    def test_minimal(self, min_user): | 
|  | 45 | +        assert is_valid(min_user) | 
|  | 46 | + | 
|  | 47 | +    def test_full(self, full_user): | 
|  | 48 | +        assert is_valid(full_user) | 
|  | 49 | + | 
|  | 50 | +    @pytest.mark.parametrize('age', range(18)) | 
|  | 51 | +    def test_invalid_age_too_young(self, age, min_user): | 
|  | 52 | +        min_user['age'] = age | 
|  | 53 | + | 
|  | 54 | +        assert not is_valid(min_user) | 
|  | 55 | + | 
|  | 56 | +    @pytest.mark.parametrize('age', range(66, 100)) | 
|  | 57 | +    def test_invalid_age_too_old(self, age, min_user): | 
|  | 58 | +        min_user['age'] = age | 
|  | 59 | + | 
|  | 60 | +        assert not is_valid(min_user) | 
|  | 61 | + | 
|  | 62 | +    @pytest.mark.parametrize('age', ['NaN', 3.1415, None]) | 
|  | 63 | +    def test_invalid_age_wrong_type(self, age, min_user): | 
|  | 64 | +        min_user['age'] = age | 
|  | 65 | + | 
|  | 66 | +        assert not is_valid(min_user) | 
|  | 67 | + | 
|  | 68 | +    @pytest.mark.parametrize('age', range(18, 66)) | 
|  | 69 | +    def test_valid_age(self, age, min_user): | 
|  | 70 | +        min_user['age'] = age | 
|  | 71 | + | 
|  | 72 | +        assert is_valid(min_user) | 
|  | 73 | + | 
|  | 74 | +    @pytest.mark.parametrize('field', ['email', 'name', 'age']) | 
|  | 75 | +    def test_mandatory_fields(self, field, min_user): | 
|  | 76 | +        del min_user[field] | 
|  | 77 | + | 
|  | 78 | +        assert not is_valid(min_user) | 
|  | 79 | + | 
|  | 80 | +    @pytest.mark.parametrize('field', ['email', 'name', 'age']) | 
|  | 81 | +    def test_mandatory_fields_empty(self, field, min_user): | 
|  | 82 | +        min_user[field] = '' | 
|  | 83 | + | 
|  | 84 | +        assert not is_valid(min_user) | 
|  | 85 | + | 
|  | 86 | +    def test_name_whitespace_only(self, min_user): | 
|  | 87 | +        min_user['name'] = ' \n\t' | 
|  | 88 | + | 
|  | 89 | +        assert not is_valid(min_user) | 
|  | 90 | + | 
|  | 91 | +    @pytest.mark.parametrize( | 
|  | 92 | +        'email, outcome', | 
|  | 93 | +        [ | 
|  | 94 | +            ('missing_at.com', False), | 
|  | 95 | +            ('@missing_start.com', False), | 
|  | 96 | +            ('missing_end@', False), | 
|  | 97 | +            ('missing_dot@example', False), | 
|  | 98 | +
 | 
|  | 99 | + | 
|  | 100 | +            ('δοκιμή@παράδειγμα.δοκιμή', True), | 
|  | 101 | +            ('аджай@экзампл.рус', True), | 
|  | 102 | +        ] | 
|  | 103 | +    ) | 
|  | 104 | +    def test_email(self, email, outcome, min_user): | 
|  | 105 | +        min_user['email'] = email | 
|  | 106 | + | 
|  | 107 | +        assert is_valid(min_user) == outcome | 
|  | 108 | + | 
|  | 109 | +    @pytest.mark.parametrize( | 
|  | 110 | +        'field, value', | 
|  | 111 | +        [ | 
|  | 112 | +            ('email', None), | 
|  | 113 | +            ('email', 3.1415), | 
|  | 114 | +            ('email', {}), | 
|  | 115 | +
 | 
|  | 116 | +            ('name', None), | 
|  | 117 | +            ('name', 3.1415), | 
|  | 118 | +            ('name', {}), | 
|  | 119 | +
 | 
|  | 120 | +            ('role', None), | 
|  | 121 | +            ('role', 3.1415), | 
|  | 122 | +            ('role', {}), | 
|  | 123 | +        ] | 
|  | 124 | +    ) | 
|  | 125 | +    def test_invalid_types(self, field, value, min_user): | 
|  | 126 | +        min_user[field] = value | 
|  | 127 | + | 
|  | 128 | +        assert not is_valid(min_user) | 
|  | 129 | + | 
|  | 130 | + | 
|  | 131 | +class TestExport: | 
|  | 132 | +    """Test behavior of `export` function. """ | 
|  | 133 | + | 
|  | 134 | +    @pytest.fixture | 
|  | 135 | +    def csv_file(self, tmp_path): | 
|  | 136 | +        """Yield a filename in a temporary folder. | 
|  | 137 | +
 | 
|  | 138 | +        Due to how pytest `tmp_path` fixture works, the file does | 
|  | 139 | +        not exist yet. | 
|  | 140 | +        """ | 
|  | 141 | +        yield tmp_path / "out.csv" | 
|  | 142 | + | 
|  | 143 | +    @pytest.fixture | 
|  | 144 | +    def existing_file(self, tmp_path): | 
|  | 145 | +        """Create a temporary file and put some content in it. """ | 
|  | 146 | +        existing = tmp_path / 'existing.csv' | 
|  | 147 | +        existing.write_text('Please leave me alone...') | 
|  | 148 | +        yield existing | 
|  | 149 | + | 
|  | 150 | +    def test_export(self, users, csv_file): | 
|  | 151 | +        export(csv_file, users) | 
|  | 152 | + | 
|  | 153 | +        text = csv_file.read_text() | 
|  | 154 | + | 
|  | 155 | +        assert ( | 
|  | 156 | +            'email,name,age,role\n' | 
|  | 157 | +            '[email protected],Primus Minimus,18,\n' | 
|  | 158 | +            '[email protected],Maximus Plenus,65,emperor\n' | 
|  | 159 | +         ) == text | 
|  | 160 | + | 
|  | 161 | +    def test_export_quoting(self, min_user, csv_file): | 
|  | 162 | +        min_user['name'] = 'A name, with a comma' | 
|  | 163 | + | 
|  | 164 | +        export(csv_file, [min_user]) | 
|  | 165 | + | 
|  | 166 | +        text = csv_file.read_text() | 
|  | 167 | + | 
|  | 168 | +        assert ( | 
|  | 169 | +            'email,name,age,role\n' | 
|  | 170 | +            '[email protected],"A name, with a comma",18,\n' | 
|  | 171 | +         ) == text | 
|  | 172 | + | 
|  | 173 | +    def test_does_not_overwrite(self, users, existing_file): | 
|  | 174 | +        with pytest.raises(IOError) as err: | 
|  | 175 | +            export(existing_file, users, overwrite=False) | 
|  | 176 | + | 
|  | 177 | +        err.match( | 
|  | 178 | +            r"'{}' already exists\.".format( | 
|  | 179 | +                re.escape(str(existing_file)) | 
|  | 180 | +            ) | 
|  | 181 | +        ) | 
|  | 182 | + | 
|  | 183 | +        # let's also verify the file is still intact | 
|  | 184 | +        assert existing_file.read_text() == ( | 
|  | 185 | +            'Please leave me alone...' | 
|  | 186 | +        ) | 
|  | 187 | + | 
|  | 188 | + | 
|  | 189 | +class TextExportMock: | 
|  | 190 | +    """Example on how to test with mocks. """ | 
|  | 191 | + | 
|  | 192 | +    @pytest.fixture | 
|  | 193 | +    def write_csv_mock(self): | 
|  | 194 | +        with patch('ch10.api.write_csv') as m: | 
|  | 195 | +            yield m | 
|  | 196 | + | 
|  | 197 | +    @pytest.fixture | 
|  | 198 | +    def get_valid_users_mock(self): | 
|  | 199 | +        with patch('ch10.api.get_valid_users') as m: | 
|  | 200 | +            yield m | 
|  | 201 | + | 
|  | 202 | +    def test_export( | 
|  | 203 | +        self, write_csv_mock, get_valid_users_mock, users | 
|  | 204 | +    ): | 
|  | 205 | +        export('out.csv', users) | 
|  | 206 | + | 
|  | 207 | +        # verify mocked funcs have been called properly | 
|  | 208 | +        assert [call(users)] == get_valid_users_mock.call_args_list | 
|  | 209 | + | 
|  | 210 | +        valid_users = get_valid_users_mock.return_value | 
|  | 211 | +        assert [ | 
|  | 212 | +            call('out.csv', valid_users) | 
|  | 213 | +        ] == write_csv_mock.call_args_list | 
|  | 214 | + | 
|  | 215 | + | 
|  | 216 | +class TestWriteCSV: | 
|  | 217 | +    """Example on how to test with mocks. """ | 
|  | 218 | + | 
|  | 219 | +    @pytest.fixture | 
|  | 220 | +    def open_mock(self): | 
|  | 221 | +        """Mocks the `open` function. """ | 
|  | 222 | +        with patch('builtins.open', new_callable=mock_open()) as m: | 
|  | 223 | +            yield m | 
|  | 224 | + | 
|  | 225 | +    @pytest.fixture | 
|  | 226 | +    def csv_mock(self): | 
|  | 227 | +        """Mocks the `csv` module as imported in `api.py`. """ | 
|  | 228 | +        with patch('ch10.api.csv') as m: | 
|  | 229 | +            yield m | 
|  | 230 | + | 
|  | 231 | +    def test_write_csv(self, open_mock, csv_mock, users): | 
|  | 232 | +        fieldnames = ['email', 'name', 'age', 'role'] | 
|  | 233 | + | 
|  | 234 | +        write_csv('out.csv', users) | 
|  | 235 | + | 
|  | 236 | +        # verify both mocks are at work properly | 
|  | 237 | +        writer = csv_mock.DictWriter.return_value | 
|  | 238 | +        managed = open_mock().__enter__() | 
|  | 239 | + | 
|  | 240 | +        assert [ | 
|  | 241 | +            call(managed, fieldnames=fieldnames) | 
|  | 242 | +        ] == csv_mock.DictWriter.call_args_list | 
|  | 243 | + | 
|  | 244 | +        assert [call()] == writer.writeheader.call_args_list | 
|  | 245 | + | 
|  | 246 | +        assert [ | 
|  | 247 | +            call(users[0]), call(users[1]), call(users[2]) | 
|  | 248 | +        ] == writer.writerow.call_args_list | 
0 commit comments