Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a test to check that a bad response from the openmarket API correctly raises an exception #471

Merged
merged 6 commits into from
Nov 22, 2021

Conversation

H-Shay
Copy link
Contributor

@H-Shay H-Shay commented Nov 19, 2021

As the title states.

@H-Shay H-Shay requested a review from a team as a code owner November 19, 2021 19:43
Copy link
Contributor

@DMRobertson DMRobertson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks for this, it's always good to improve test coverage! I think there's a mistake here though which we should correct.

I've tried to explain this and my thought process below. I, err, think I went over the top and wrote too much. I hope it's helpful and followable rather than intimidating! Please do let me know if anything's unclear and we can work through it.

changelog.d/468.misc Outdated Show resolved Hide resolved
tests/test_msisdn.py Outdated Show resolved Hide resolved
tests/test_msisdn.py Outdated Show resolved Hide resolved
tests/test_msisdn.py Outdated Show resolved Hide resolved
tests/test_msisdn.py Show resolved Hide resolved
Comment on lines 120 to 122
self.assertRaises(
Exception, request.render(self.sydent.servlets.msisdnRequestCode)
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly surprised this works. My understanding is that there are two forms of assertRaises:

  • the function call form here, which will call first its argument and check that doing so raises
  • the context manager form, which checks that evaluating an expression raises.

See https://docs.python.org/3/library/unittest.html?highlight=assertraises#unittest.TestCase.assertRaises

I think what's actually happening here is:

  1. We evaluate the expression request.render(self.sydent.servlets.msisdnRequestCode)
  2. Doing so calls self.sydent.servlets.msisdnRequestCode.render(request).
  3. We eventually call this line which uses the patch/mock defined above. resp ends up being the FakeResponse created earlier.

resp, response_body = await self.http_cli.post_json_maybe_get_json(
API_BASE_URL, cast(JsonDict, send_body), {"headers": req_headers}
)

  1. Because resp.code == 400, we raise an Exception here:

if resp.code < 200 or resp.code >= 300:
if response_body is None or "error" not in response_body:
raise Exception(
"OpenMarket API responded with status %d (request ID: %s)"
% (
resp.code,
request_id,
),
)

  1. BUT, crucially, that gets caught in the second except block here:

try:
sid = await self.sydent.validators.msisdn.requestToken(
phone_number_object, clientSecret, sendAttempt, brand
)
resp = {
"success": True,
"sid": str(sid),
"msisdn": msisdn,
"intl_fmt": intl_fmt,
}
except DestinationRejectedException:
logger.warning("Destination rejected for number: %s", msisdn)
request.setResponseCode(400)
resp = {
"errcode": "M_DESTINATION_REJECTED",
"error": "Phone numbers in this country are not currently supported",
}
except Exception:
logger.exception("Exception sending SMS")
request.setResponseCode(500)
resp = {"errcode": "M_UNKNOWN", "error": "Internal Server Error"}

  1. render_POST continues execution and returns None.
  2. twisted.web.server.Request.render continues execution and returns None.
  3. Having evaluated the second argument to assertRaises, we then call self.assertRaises(Exception, None).
  4. Doing so tries to call None(). This raises TypeError: 'NoneType' object is not callable.
  5. TypeError is a subclass of Exception, so assertRaises sees the behaviour it expects. (C.f. the stdlib's exception hierarchy.)
  6. The test passes.

Sorry, this has become a wall of text. I think there are two key corrections here:

  • assertRaises in the form used here first evaluates and then calls its second argument. To check that evaluating an expression/or statements raises an exception, use the context manager form.
  • assertRaises checks that something raises an exception which is not suppressed. Any exception that are raised, caught and not reraised (points 4 and 5) are an implementation detail and not part of the externally testable behaviour.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(As a sanity check, try making the mock return 200 instead of 400. I think you'll see that the test still passes and that we are therefore insane.)

I suggest taking a moment to consider what is under test here, and what the test wants to ensure. We agree on the scenario: the openmarket API returns 400.

  • What thing are we testing in this circumstance?
  • What is the behaviour we want to ensure occurs? (I.e. what can we detect/measure?)

It looks to me like we're testing Sydent's handling of the request as a whole, as if we're a user of the API. The user knows that something has gone wrong only by inspecting the HTTP response. I suggest this test should do so too; I think we can do this by interrogating the channel.

Note: your instinct is good here! It would be very reasonable to test that sendTextSMS raises an Exception when its call to post_json_maybe_get_json returns 400. It'd make for a great unit test of sydent.sms.openmarket. What we have above---making the request and checking the response---is higher level, and tests multiple different layers interacting together. It's more of an integration or functional test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this write-up! I really appreciate it, and it is very helpful/edifying!

@H-Shay H-Shay requested a review from DMRobertson November 22, 2021 19:11
Copy link
Contributor

@DMRobertson DMRobertson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! (I hope the write-up was useful)

@@ -101,7 +102,9 @@ def test_request_code_via_url_query_params(self) -> None:
self.assertEqual(channel.code, 200)

@patch("sydent.http.httpclient.HTTPClient.post_json_maybe_get_json")
def test_bad_api_response_raises_exception(self, post_json) -> None:
def test_bad_api_response_raises_exception(
self, post_json: Union[AsyncMock, MagicMock]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of interest, is this AsyncMock on 3.8+ and MagicMock otherwise?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that was the idea, although I think I changed it to MagicMock and mypy is still happy.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd guess that patch isn't annotated so it can't really check the details. And also,AsyncMock and MagicMock are so magic anyway that, erm, I'm not sure there's much for it to check. Maybe I was a little too keen there!

@H-Shay H-Shay merged commit c5f1300 into main Nov 22, 2021
@H-Shay H-Shay deleted the shay/add_test branch November 22, 2021 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants