Skip to content

Commit d40f708

Browse files
committed
In tests replace live DNS queries with mocked DNS answers that have been captured to a JSON file
1 parent 7798028 commit d40f708

File tree

7 files changed

+245
-56
lines changed

7 files changed

+245
-56
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,8 @@ pip install -r test_requirements.txt
416416
make test
417417
```
418418

419+
Tests run with mocked DNS responses. When adding or changing tests, temporarily turn on the `BUILD_MOCKED_DNS_RESPONSE_DATA` flag in `tests/mocked_dns_responses.py` to re-build the database of mocked responses from live queries.
420+
419421
For Project Maintainers
420422
-----------------------
421423

email_validator/__main__.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
from .exceptions_types import EmailNotValidError
1818

1919

20-
def main():
20+
def main(dns_resolver=None):
21+
# The dns_resolver argument is for tests.
22+
2123
if len(sys.argv) == 1:
2224
# Validate the email addresses pased line-by-line on STDIN.
23-
dns_resolver = caching_resolver()
25+
dns_resolver = dns_resolver or caching_resolver()
2426
for line in sys.stdin:
2527
email = line.strip()
2628
try:
@@ -31,7 +33,7 @@ def main():
3133
# Validate the email address passed on the command line.
3234
email = sys.argv[1]
3335
try:
34-
result = validate_email(email)
36+
result = validate_email(email, dns_resolver=dns_resolver)
3537
print(json.dumps(result.as_dict(), indent=2, sort_keys=True, ensure_ascii=False))
3638
except EmailNotValidError as e:
3739
print(e)

email_validator/deliverability.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,6 @@ def validate_email_deliverability(domain, domain_i18n, timeout=None, dns_resolve
3333
deliverability_info = {}
3434

3535
try:
36-
# We need a way to check how timeouts are handled in the tests. So we
37-
# have a secret variable that if set makes this method always test the
38-
# handling of a timeout.
39-
if getattr(validate_email_deliverability, 'TEST_CHECK_TIMEOUT', False):
40-
raise dns.exception.Timeout()
41-
4236
try:
4337
# Try resolving for MX records.
4438
response = dns_resolver.resolve(domain, "MX")

tests/mocked-dns-answers.json

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
[
2+
{
3+
"query": {
4+
"name": "gmail.com",
5+
"type": "MX",
6+
"class": "IN"
7+
},
8+
"answer": [
9+
"10 alt1.gmail-smtp-in.l.google.com.",
10+
"30 alt3.gmail-smtp-in.l.google.com.",
11+
"5 gmail-smtp-in.l.google.com.",
12+
"20 alt2.gmail-smtp-in.l.google.com.",
13+
"40 alt4.gmail-smtp-in.l.google.com."
14+
]
15+
},
16+
{
17+
"query": {
18+
"name": "xkxufoekjvjfjeodlfmdfjcu.com",
19+
"type": "ANY",
20+
"class": "IN"
21+
},
22+
"answer": []
23+
},
24+
{
25+
"query": {
26+
"name": "xkxufoekjvjfjeodlfmdfjcu.com",
27+
"type": "AAAA",
28+
"class": "IN"
29+
},
30+
"answer": []
31+
},
32+
{
33+
"query": {
34+
"name": "example.com",
35+
"type": "MX",
36+
"class": "IN"
37+
},
38+
"answer": [
39+
"0 ."
40+
]
41+
},
42+
{
43+
"query": {
44+
"name": "mail.example",
45+
"type": "MX",
46+
"class": "IN"
47+
},
48+
"answer": []
49+
},
50+
{
51+
"query": {
52+
"name": "mail.example",
53+
"type": "ANY",
54+
"class": "IN"
55+
},
56+
"answer": []
57+
},
58+
{
59+
"query": {
60+
"name": "mail.example",
61+
"type": "AAAA",
62+
"class": "IN"
63+
},
64+
"answer": []
65+
},
66+
{
67+
"query": {
68+
"name": "mail.example.com",
69+
"type": "MX",
70+
"class": "IN"
71+
},
72+
"answer": []
73+
},
74+
{
75+
"query": {
76+
"name": "mail.example.com",
77+
"type": "ANY",
78+
"class": "IN"
79+
},
80+
"answer": []
81+
},
82+
{
83+
"query": {
84+
"name": "mail.example.com",
85+
"type": "AAAA",
86+
"class": "IN"
87+
},
88+
"answer": []
89+
},
90+
{
91+
"query": {
92+
"name": "google.com",
93+
"type": "MX",
94+
"class": "IN"
95+
},
96+
"answer": [
97+
"10 smtp.google.com."
98+
]
99+
}
100+
]

tests/mocked_dns_response.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import dns.resolver
2+
import json
3+
import os.path
4+
import pytest
5+
6+
from email_validator.deliverability import caching_resolver
7+
8+
# To run deliverability checks without actually making
9+
# DNS queries, we use a caching resolver where the cache
10+
# is pre-loaded with DNS responses.
11+
12+
# When False, all DNS queries must come from the mocked
13+
# data. When True, tests are run with live DNS queries
14+
# and the DNS responses are saved to a file.
15+
BUILD_MOCKED_DNS_RESPONSE_DATA = False
16+
17+
18+
# This class implements the 'get' and 'put' methods
19+
# expected for a dns.resolver.Resolver's cache.
20+
class MockedDnsResponseData:
21+
DATA_PATH = os.path.dirname(__file__) + "/mocked-dns-answers.json"
22+
23+
@staticmethod
24+
def create_resolver():
25+
if not hasattr(MockedDnsResponseData, 'INSTANCE'):
26+
# Create a singleton instance of this class and load the saved DNS responses.
27+
# Except when BUILD_MOCKED_DNS_RESPONSE_DATA is true, don't load the data.
28+
singleton = MockedDnsResponseData()
29+
if not BUILD_MOCKED_DNS_RESPONSE_DATA:
30+
singleton.load()
31+
MockedDnsResponseData.INSTANCE = singleton
32+
33+
# Return a new dns.resolver.Resolver configured for caching
34+
# using the singleton instance.
35+
return caching_resolver(cache=MockedDnsResponseData.INSTANCE)
36+
37+
def __init__(self):
38+
self.data = {}
39+
40+
def load(self):
41+
# Loads the saved DNS response data from the JSON file and
42+
# re-structures it into dnspython classes.
43+
class Ans: # mocks the dns.resolver.Answer class
44+
45+
def __init__(self, rrset):
46+
self.rrset = rrset
47+
48+
def __iter__(self):
49+
return iter(self.rrset)
50+
51+
with open(self.DATA_PATH) as f:
52+
data = json.load(f)
53+
for item in data:
54+
key = (dns.name.from_text(item["query"]["name"] + "."),
55+
dns.rdatatype.from_text(item["query"]["type"]),
56+
dns.rdataclass.from_text(item["query"]["class"]))
57+
rdatas = [
58+
dns.rdata.from_text(rdtype=key[1], rdclass=key[2], tok=rr)
59+
for rr in item["answer"]
60+
]
61+
if item["answer"]:
62+
self.data[key] = Ans(dns.rdataset.from_rdata_list(0, rdatas=rdatas))
63+
else:
64+
self.data[key] = None
65+
66+
def save(self):
67+
# Re-structure as a list with basic data types.
68+
data = [
69+
{
70+
"query": {
71+
"name": key[0].to_text(omit_final_dot=True),
72+
"type": dns.rdatatype.to_text(key[1]),
73+
"class": dns.rdataclass.to_text(key[2]),
74+
},
75+
"answer": [
76+
rr.to_text()
77+
for rr in value
78+
]
79+
}
80+
for key, value in self.data.items()
81+
]
82+
with open(self.DATA_PATH, "w") as f:
83+
json.dump(data, f, indent=True)
84+
85+
def get(self, key):
86+
# Special-case a domain to create a timeout.
87+
if key[0].to_text() == "timeout.com.":
88+
raise dns.exception.Timeout()
89+
90+
# When building the DNS response database, return
91+
# a cache miss.
92+
if BUILD_MOCKED_DNS_RESPONSE_DATA:
93+
return None
94+
95+
# Query the data for a matching record.
96+
if key in self.data:
97+
if not self.data[key]:
98+
raise dns.resolver.NoAnswer()
99+
return self.data[key]
100+
101+
# Query the data for a response to an ANY query.
102+
ANY = dns.rdatatype.from_text("ANY")
103+
if (key[0], ANY, key[2]) in self.data and self.data[(key[0], ANY, key[2])] is None:
104+
raise dns.resolver.NoAnswer()
105+
106+
raise ValueError("Saved DNS data did not contain query: {}".format(key))
107+
108+
def put(self, key, value):
109+
# Build the DNS data by saving the live query response.
110+
if not BUILD_MOCKED_DNS_RESPONSE_DATA:
111+
raise ValueError("Should not get here.")
112+
self.data[key] = value
113+
114+
115+
@pytest.fixture(scope="session", autouse=True)
116+
def MockedDnsResponseDataCleanup(request):
117+
def cleanup_func():
118+
if BUILD_MOCKED_DNS_RESPONSE_DATA:
119+
MockedDnsResponseData.INSTANCE.save()
120+
request.addfinalizer(cleanup_func)

tests/test_deliverability.py

Lines changed: 10 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import dns.resolver
21
import pytest
32
import re
43

54
from email_validator import EmailUndeliverableError, \
65
validate_email
7-
from email_validator.deliverability import caching_resolver, validate_email_deliverability
6+
from email_validator.deliverability import validate_email_deliverability
7+
8+
from mocked_dns_response import MockedDnsResponseData, MockedDnsResponseDataCleanup # noqa: F401
9+
10+
RESOLVER = MockedDnsResponseData.create_resolver()
811

912

1013
def test_deliverability_found():
11-
response = validate_email_deliverability('gmail.com', 'gmail.com')
14+
response = validate_email_deliverability('gmail.com', 'gmail.com', dns_resolver=RESOLVER)
1215
assert response.keys() == {'mx', 'mx_fallback_type'}
1316
assert response['mx_fallback_type'] is None
1417
assert len(response['mx']) > 1
@@ -21,12 +24,12 @@ def test_deliverability_fails():
2124
# No MX record.
2225
domain = 'xkxufoekjvjfjeodlfmdfjcu.com'
2326
with pytest.raises(EmailUndeliverableError, match='The domain name {} does not exist'.format(domain)):
24-
validate_email_deliverability(domain, domain)
27+
validate_email_deliverability(domain, domain, dns_resolver=RESOLVER)
2528

2629
# Null MX record.
2730
domain = 'example.com'
2831
with pytest.raises(EmailUndeliverableError, match='The domain name {} does not accept email'.format(domain)):
29-
validate_email_deliverability(domain, domain)
32+
validate_email_deliverability(domain, domain, dns_resolver=RESOLVER)
3033

3134

3235
@pytest.mark.parametrize(
@@ -41,48 +44,12 @@ def test_email_example_reserved_domain(email_input):
4144
# Since these all fail deliverabiltiy from a static list,
4245
# DNS deliverability checks do not arise.
4346
with pytest.raises(EmailUndeliverableError) as exc_info:
44-
validate_email(email_input)
47+
validate_email(email_input, dns_resolver=RESOLVER)
4548
# print(f'({email_input!r}, {str(exc_info.value)!r}),')
4649
assert re.match(r"The domain name [a-z\.]+ does not (accept email|exist)\.", str(exc_info.value)) is not None
4750

4851

4952
def test_deliverability_dns_timeout():
50-
validate_email_deliverability.TEST_CHECK_TIMEOUT = True
51-
response = validate_email_deliverability('gmail.com', 'gmail.com')
53+
response = validate_email_deliverability('timeout.com', 'timeout.com', dns_resolver=RESOLVER)
5254
assert "mx" not in response
5355
assert response.get("unknown-deliverability") == "timeout"
54-
validate_email('test@gmail.com')
55-
del validate_email_deliverability.TEST_CHECK_TIMEOUT
56-
57-
58-
def test_validate_email__with_caching_resolver():
59-
# unittest.mock.patch("dns.resolver.LRUCache.get") doesn't
60-
# work --- it causes get to always return an empty list.
61-
# So we'll mock our own way.
62-
class MockedCache:
63-
get_called = False
64-
put_called = False
65-
66-
def get(self, key):
67-
self.get_called = True
68-
return None
69-
70-
def put(self, key, value):
71-
self.put_called = True
72-
73-
# Test with caching_resolver helper method.
74-
mocked_cache = MockedCache()
75-
dns_resolver = caching_resolver(cache=mocked_cache)
76-
validate_email("test@gmail.com", dns_resolver=dns_resolver)
77-
assert mocked_cache.put_called
78-
validate_email("test@gmail.com", dns_resolver=dns_resolver)
79-
assert mocked_cache.get_called
80-
81-
# Test with dns.resolver.Resolver instance.
82-
dns_resolver = dns.resolver.Resolver()
83-
dns_resolver.lifetime = 10
84-
dns_resolver.cache = MockedCache()
85-
validate_email("test@gmail.com", dns_resolver=dns_resolver)
86-
assert mocked_cache.put_called
87-
validate_email("test@gmail.com", dns_resolver=dns_resolver)
88-
assert mocked_cache.get_called

tests/test_main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
# Let's test main but rename it to be clear
33
from email_validator.__main__ import main as validator_command_line_tool
44

5+
from mocked_dns_response import MockedDnsResponseData, MockedDnsResponseDataCleanup # noqa: F401
6+
7+
RESOLVER = MockedDnsResponseData.create_resolver()
8+
59

610
def test_dict_accessor():
711
input_email = "testaddr@example.tld"
@@ -14,17 +18,17 @@ def test_main_single_good_input(monkeypatch, capsys):
1418
import json
1519
test_email = "google@google.com"
1620
monkeypatch.setattr('sys.argv', ['email_validator', test_email])
17-
validator_command_line_tool()
21+
validator_command_line_tool(dns_resolver=RESOLVER)
1822
stdout, _ = capsys.readouterr()
1923
output = json.loads(str(stdout))
2024
assert isinstance(output, dict)
21-
assert validate_email(test_email).original_email == output["original_email"]
25+
assert validate_email(test_email, dns_resolver=RESOLVER).original_email == output["original_email"]
2226

2327

2428
def test_main_single_bad_input(monkeypatch, capsys):
2529
bad_email = 'test@..com'
2630
monkeypatch.setattr('sys.argv', ['email_validator', bad_email])
27-
validator_command_line_tool()
31+
validator_command_line_tool(dns_resolver=RESOLVER)
2832
stdout, _ = capsys.readouterr()
2933
assert stdout == 'An email address cannot have a period immediately after the @-sign.\n'
3034

@@ -35,7 +39,7 @@ def test_main_multi_input(monkeypatch, capsys):
3539
test_input = io.StringIO("\n".join(test_cases))
3640
monkeypatch.setattr('sys.stdin', test_input)
3741
monkeypatch.setattr('sys.argv', ['email_validator'])
38-
validator_command_line_tool()
42+
validator_command_line_tool(dns_resolver=RESOLVER)
3943
stdout, _ = capsys.readouterr()
4044
assert test_cases[0] not in stdout
4145
assert test_cases[1] not in stdout

0 commit comments

Comments
 (0)