Repository: encode/httpx

Issue:
Type-checking our tests
I know this is not a standard thing to do across Encode projects, but I've been wondering if it would be worth starting to type-hint our tests.

I've seen at least two instances of this recently:

- In HTTPX: https://github.com/encode/httpx/pull/648#discussion_r359862603
- In Starlette: https://github.com/encode/starlette/issues/722

My rationale is based on two aspects:

- It improves our upfront knowledge about how users will actually use HTTPX — currently their usage of type hints in the wild is not reflected anywhere.
- It helps us catch type hint inconsistencies we wouldn't see in the core package.

The main counter-argument, I suppose, is that type hinting tests is tedious. I think that's fair, but I believe the two pro's above make it compelling.

Thoughts?


Discussion:
Alternatively, we could tweak the `mypy` config:

```ini
[mypy]
files = httpx, tests
ignore_missing_imports = True
disallow_untyped_defs = False
check_untyped_defs = True

[mypy-httpx]
disallow_untyped_defs = True
check_untyped_defs = False
```

Running this locally raises 141 errors, but mostly on internal APIs, so, hmm.
I think this is a great idea, would like to work on this. Since I am pretty new to httpx, adding these type hints to the tests will give me a better picture of how httpx works under the hood.
I think this is still a good idea, though it's a bit of busy work to upgrade our existing test code.

Let's perhaps just remind ourselves to add types whenever we add/touch existing tests?
@florimondmanca I see a lot of merged PR. I guess you should close this ? 😄 
I think there’s one small last step which would be to add tests/ to the mypy arguments in the check script. :-) Once this is enforced then yup, we can close this off.
I am currently working on a different Good First Issue PR and when that is done. I would be happy to pick up what's remaining in this PR so we can close this one too. 😄 

Resolving patch:
diff --git a/httpx/_types.py b/httpx/_types.py
--- a/httpx/_types.py
+++ b/httpx/_types.py
@@ -72,4 +72,4 @@
     # (filename, file (or text), content_type)
     Tuple[Optional[str], FileContent, Optional[str]],
 ]
-RequestFiles = Union[Mapping[str, FileTypes], List[Tuple[str, FileTypes]]]
+RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]


Test patch:
diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py
--- a/tests/client/test_async_client.py
+++ b/tests/client/test_async_client.py
@@ -174,8 +174,11 @@ def test_dispatch_deprecated():
 
 
 def test_asgi_dispatch_deprecated():
+    async def app(scope, receive, send):
+        pass
+
     with pytest.warns(DeprecationWarning) as record:
-        ASGIDispatch(None)
+        ASGIDispatch(app)
 
     assert len(record) == 1
     assert (
diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py
--- a/tests/client/test_auth.py
+++ b/tests/client/test_auth.py
@@ -11,7 +11,6 @@
     Auth,
     Client,
     DigestAuth,
-    Headers,
     ProtocolError,
     Request,
     RequestBodyUnavailable,
@@ -86,23 +85,26 @@ def __init__(
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
-        headers: typing.List[typing.Tuple[bytes, bytes]],
-        stream: ContentStream,
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
+        headers: typing.List[typing.Tuple[bytes, bytes]] = None,
+        stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
     ) -> typing.Tuple[
         bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
     ]:
         if self._response_count < self.send_response_after_attempt:
-            return self.challenge_send(method, url, headers, stream)
+            assert headers is not None
+            return self.challenge_send(method, headers)
 
         authorization = get_header_value(headers, "Authorization")
         body = JSONStream({"auth": authorization})
         return b"HTTP/1.1", 200, b"", [], body
 
     def challenge_send(
-        self, method: bytes, url: URL, headers: Headers, stream: ContentStream,
-    ) -> typing.Tuple[int, bytes, Headers, ContentStream]:
+        self, method: bytes, headers: typing.List[typing.Tuple[bytes, bytes]],
+    ) -> typing.Tuple[
+        bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
+    ]:
         self._response_count += 1
         nonce = (
             hashlib.sha256(os.urandom(8)).hexdigest()
@@ -297,7 +299,8 @@ async def test_auth_hidden_header() -> None:
 async def test_auth_invalid_type() -> None:
     url = "https://example.org/"
     client = AsyncClient(
-        transport=AsyncMockTransport(), auth="not a tuple, not a callable",
+        transport=AsyncMockTransport(),
+        auth="not a tuple, not a callable",  # type: ignore
     )
     with pytest.raises(TypeError):
         await client.get(url)
diff --git a/tests/client/test_client.py b/tests/client/test_client.py
--- a/tests/client/test_client.py
+++ b/tests/client/test_client.py
@@ -182,8 +182,11 @@ def test_dispatch_deprecated():
 
 
 def test_wsgi_dispatch_deprecated():
+    def app(start_response, environ):
+        pass
+
     with pytest.warns(DeprecationWarning) as record:
-        WSGIDispatch(None)
+        WSGIDispatch(app)
 
     assert len(record) == 1
     assert (
diff --git a/tests/client/test_cookies.py b/tests/client/test_cookies.py
--- a/tests/client/test_cookies.py
+++ b/tests/client/test_cookies.py
@@ -20,14 +20,15 @@ class MockTransport(httpcore.AsyncHTTPTransport):
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
-        headers: typing.List[typing.Tuple[bytes, bytes]],
-        stream: ContentStream,
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
+        headers: typing.List[typing.Tuple[bytes, bytes]] = None,
+        stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
     ) -> typing.Tuple[
         bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
     ]:
         host, scheme, port, path = url
+        body: ContentStream
         if path.startswith(b"/echo_cookies"):
             cookie = get_header_value(headers, "cookie")
             body = JSONStream({"cookies": cookie})
diff --git a/tests/client/test_headers.py b/tests/client/test_headers.py
--- a/tests/client/test_headers.py
+++ b/tests/client/test_headers.py
@@ -13,13 +13,14 @@ class MockTransport(httpcore.AsyncHTTPTransport):
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
-        headers: typing.List[typing.Tuple[bytes, bytes]],
-        stream: ContentStream,
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
+        headers: typing.List[typing.Tuple[bytes, bytes]] = None,
+        stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
     ) -> typing.Tuple[
         bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
     ]:
+        assert headers is not None
         headers_dict = {
             key.decode("ascii"): value.decode("ascii") for key, value in headers
         }
diff --git a/tests/client/test_properties.py b/tests/client/test_properties.py
--- a/tests/client/test_properties.py
+++ b/tests/client/test_properties.py
@@ -3,14 +3,14 @@
 
 def test_client_headers():
     client = AsyncClient()
-    client.headers = {"a": "b"}
+    client.headers = {"a": "b"}  # type: ignore
     assert isinstance(client.headers, Headers)
     assert client.headers["A"] == "b"
 
 
 def test_client_cookies():
     client = AsyncClient()
-    client.cookies = {"a": "b"}
+    client.cookies = {"a": "b"}  # type: ignore
     assert isinstance(client.cookies, Cookies)
     mycookies = list(client.cookies.jar)
     assert len(mycookies) == 1
diff --git a/tests/client/test_proxies.py b/tests/client/test_proxies.py
--- a/tests/client/test_proxies.py
+++ b/tests/client/test_proxies.py
@@ -1,3 +1,4 @@
+import httpcore
 import pytest
 
 import httpx
@@ -24,7 +25,9 @@ def test_proxies_parameter(proxies, expected_proxies):
 
     for proxy_key, url in expected_proxies:
         assert proxy_key in client.proxies
-        assert client.proxies[proxy_key].proxy_origin == httpx.URL(url).raw[:3]
+        proxy = client.proxies[proxy_key]
+        assert isinstance(proxy, httpcore.AsyncHTTPProxy)
+        assert proxy.proxy_origin == httpx.URL(url).raw[:3]
 
     assert len(expected_proxies) == len(client.proxies)
 
@@ -81,6 +84,7 @@ def test_transport_for_request(url, proxies, expected):
     if expected is None:
         assert transport is client.transport
     else:
+        assert isinstance(transport, httpcore.AsyncHTTPProxy)
         assert transport.proxy_origin == httpx.URL(expected).raw[:3]
 
 
diff --git a/tests/client/test_queryparams.py b/tests/client/test_queryparams.py
--- a/tests/client/test_queryparams.py
+++ b/tests/client/test_queryparams.py
@@ -3,7 +3,7 @@
 import httpcore
 import pytest
 
-from httpx import URL, AsyncClient, Headers, QueryParams
+from httpx import URL, AsyncClient, QueryParams
 from httpx._content_streams import ContentStream, JSONStream
 
 
@@ -11,16 +11,15 @@ class MockTransport(httpcore.AsyncHTTPTransport):
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
-        headers: typing.List[typing.Tuple[bytes, bytes]],
-        stream: ContentStream,
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
+        headers: typing.List[typing.Tuple[bytes, bytes]] = None,
+        stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
     ) -> typing.Tuple[
         bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
     ]:
-        headers = Headers()
         body = JSONStream({"ok": "ok"})
-        return b"HTTP/1.1", 200, b"OK", headers, body
+        return b"HTTP/1.1", 200, b"OK", [], body
 
 
 def test_client_queryparams():
@@ -35,7 +34,7 @@ def test_client_queryparams_string():
     assert client.params["a"] == "b"
 
     client = AsyncClient()
-    client.params = "a=b"
+    client.params = "a=b"  # type: ignore
     assert isinstance(client.params, QueryParams)
     assert client.params["a"] == "b"
 
diff --git a/tests/client/test_redirects.py b/tests/client/test_redirects.py
--- a/tests/client/test_redirects.py
+++ b/tests/client/test_redirects.py
@@ -103,8 +103,8 @@ async def body():
             headers_dict = {
                 key.decode("ascii"): value.decode("ascii") for key, value in headers
             }
-            content = ByteStream(json.dumps({"headers": headers_dict}).encode())
-            return b"HTTP/1.1", 200, b"OK", [], content
+            stream = ByteStream(json.dumps({"headers": headers_dict}).encode())
+            return b"HTTP/1.1", 200, b"OK", [], stream
 
         elif path == b"/redirect_body":
             code = codes.PERMANENT_REDIRECT
@@ -121,10 +121,10 @@ async def body():
             headers_dict = {
                 key.decode("ascii"): value.decode("ascii") for key, value in headers
             }
-            body = ByteStream(
+            stream = ByteStream(
                 json.dumps({"body": content.decode(), "headers": headers_dict}).encode()
             )
-            return b"HTTP/1.1", 200, b"OK", [], body
+            return b"HTTP/1.1", 200, b"OK", [], stream
 
         elif path == b"/cross_subdomain":
             host = get_header_value(headers, "host")
@@ -402,9 +402,9 @@ class MockCookieTransport(httpcore.AsyncHTTPTransport):
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
-        headers: typing.List[typing.Tuple[bytes, bytes]],
-        stream: ContentStream,
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
+        headers: typing.List[typing.Tuple[bytes, bytes]] = None,
+        stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
     ) -> typing.Tuple[
         bytes, int, bytes, typing.List[typing.Tuple[bytes, bytes]], ContentStream
@@ -432,7 +432,8 @@ async def request(
             ]
             return b"HTTP/1.1", status_code, b"See Other", headers, ByteStream(b"")
 
-        elif path == b"/logout":
+        else:
+            assert path == b"/logout"
             status_code = codes.SEE_OTHER
             headers = [
                 (b"location", b"/"),
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -56,7 +56,7 @@ async def my_async_test():
 
 
 @pytest.fixture(scope="function", autouse=True)
-def clean_environ() -> typing.Dict[str, typing.Any]:
+def clean_environ():
     """Keeps os.environ clean for every test without having to mock os.environ"""
     original_environ = os.environ.copy()
     os.environ.clear()
diff --git a/tests/test_api.py b/tests/test_api.py
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -68,7 +68,6 @@ def test_stream(server):
     assert response.http_version == "HTTP/1.1"
 
 
-@pytest.mark.asyncio
-async def test_get_invalid_url(server):
+def test_get_invalid_url():
     with pytest.raises(httpx.InvalidURL):
-        await httpx.get("invalid://example.org")
+        httpx.get("invalid://example.org")
diff --git a/tests/test_config.py b/tests/test_config.py
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -56,7 +56,7 @@ def test_load_ssl_config_verify_env_file(https_server, ca_cert_pem_file, config)
 
 def test_load_ssl_config_verify_directory():
     path = Path(certifi.where()).parent
-    ssl_config = SSLConfig(verify=path)
+    ssl_config = SSLConfig(verify=str(path))
     context = ssl_config.ssl_context
     assert context.verify_mode == ssl.VerifyMode.CERT_REQUIRED
     assert context.check_hostname is True
@@ -192,7 +192,7 @@ def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch):  # pragma: noc
 
         ssl_config = SSLConfig(trust_env=True)
 
-        assert ssl_config.ssl_context.keylog_filename is None
+        assert ssl_config.ssl_context.keylog_filename is None  # type: ignore
 
     filename = str(tmpdir.join("test.log"))
 
@@ -201,11 +201,11 @@ def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch):  # pragma: noc
 
         ssl_config = SSLConfig(trust_env=True)
 
-        assert ssl_config.ssl_context.keylog_filename == filename
+        assert ssl_config.ssl_context.keylog_filename == filename  # type: ignore
 
         ssl_config = SSLConfig(trust_env=False)
 
-        assert ssl_config.ssl_context.keylog_filename is None
+        assert ssl_config.ssl_context.keylog_filename is None  # type: ignore
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_content_streams.py b/tests/test_content_streams.py
--- a/tests/test_content_streams.py
+++ b/tests/test_content_streams.py
@@ -203,7 +203,7 @@ async def test_empty_request():
 
 def test_invalid_argument():
     with pytest.raises(TypeError):
-        encode(123)
+        encode(123)  # type: ignore
 
 
 @pytest.mark.asyncio
diff --git a/tests/test_multipart.py b/tests/test_multipart.py
--- a/tests/test_multipart.py
+++ b/tests/test_multipart.py
@@ -8,7 +8,7 @@
 import pytest
 
 import httpx
-from httpx._content_streams import AsyncIteratorStream, encode
+from httpx._content_streams import AsyncIteratorStream, MultipartStream, encode
 from httpx._utils import format_form_param
 
 
@@ -16,7 +16,7 @@ class MockTransport(httpcore.AsyncHTTPTransport):
     async def request(
         self,
         method: bytes,
-        url: typing.Tuple[bytes, bytes, int, bytes],
+        url: typing.Tuple[bytes, bytes, typing.Optional[int], bytes],
         headers: typing.List[typing.Tuple[bytes, bytes]] = None,
         stream: httpcore.AsyncByteStream = None,
         timeout: typing.Dict[str, typing.Optional[float]] = None,
@@ -27,6 +27,7 @@ async def request(
         typing.List[typing.Tuple[bytes, bytes]],
         httpcore.AsyncByteStream,
     ]:
+        assert stream is not None
         content = AsyncIteratorStream(aiterator=(part async for part in stream))
         return b"HTTP/1.1", 200, b"OK", [], content
 
@@ -46,7 +47,10 @@ async def test_multipart(value, output):
     # bit grungy, but sufficient just for our testing purposes.
     boundary = response.request.headers["Content-Type"].split("boundary=")[-1]
     content_length = response.request.headers["Content-Length"]
-    pdict = {"boundary": boundary.encode("ascii"), "CONTENT-LENGTH": content_length}
+    pdict: dict = {
+        "boundary": boundary.encode("ascii"),
+        "CONTENT-LENGTH": content_length,
+    }
     multipart = cgi.parse_multipart(io.BytesIO(response.content), pdict)
 
     # Note that the expected return type for text fields
@@ -91,7 +95,10 @@ async def test_multipart_file_tuple():
     # bit grungy, but sufficient just for our testing purposes.
     boundary = response.request.headers["Content-Type"].split("boundary=")[-1]
     content_length = response.request.headers["Content-Length"]
-    pdict = {"boundary": boundary.encode("ascii"), "CONTENT-LENGTH": content_length}
+    pdict: dict = {
+        "boundary": boundary.encode("ascii"),
+        "CONTENT-LENGTH": content_length,
+    }
     multipart = cgi.parse_multipart(io.BytesIO(response.content), pdict)
 
     # Note that the expected return type for text fields
@@ -117,6 +124,7 @@ def test_multipart_encode(tmp_path: typing.Any) -> None:
         boundary = os.urandom(16).hex()
 
         stream = encode(data=data, files=files)
+        assert isinstance(stream, MultipartStream)
         assert stream.can_replay()
 
         assert stream.content_type == f"multipart/form-data; boundary={boundary}"
@@ -143,6 +151,7 @@ def test_multipart_encode_files_allows_filenames_as_none() -> None:
         boundary = os.urandom(16).hex()
 
         stream = encode(data={}, files=files)
+        assert isinstance(stream, MultipartStream)
         assert stream.can_replay()
 
         assert stream.content_type == f"multipart/form-data; boundary={boundary}"
@@ -169,6 +178,7 @@ def test_multipart_encode_files_guesses_correct_content_type(
         boundary = os.urandom(16).hex()
 
         stream = encode(data={}, files=files)
+        assert isinstance(stream, MultipartStream)
         assert stream.can_replay()
 
         assert stream.content_type == f"multipart/form-data; boundary={boundary}"
@@ -192,6 +202,7 @@ def test_multipart_encode_files_allows_bytes_or_str_content(
         boundary = os.urandom(16).hex()
 
         stream = encode(data={}, files=files)
+        assert isinstance(stream, MultipartStream)
         assert stream.can_replay()
 
         assert stream.content_type == f"multipart/form-data; boundary={boundary}"
@@ -226,7 +237,7 @@ def data() -> typing.Iterator[bytes]:
         yield b"Hello"
         yield b"World"
 
-    fileobj = IteratorIO(data())
+    fileobj: typing.Any = IteratorIO(data())
     files = {"file": fileobj}
     stream = encode(files=files, boundary=b"+++")
     assert not stream.can_replay()
diff --git a/tests/test_status_codes.py b/tests/test_status_codes.py
--- a/tests/test_status_codes.py
+++ b/tests/test_status_codes.py
@@ -7,7 +7,7 @@ def test_status_code_as_int():
 
 
 def test_lowercase_status_code():
-    assert httpx.codes.not_found == 404
+    assert httpx.codes.not_found == 404  # type: ignore
 
 
 def test_reason_phrase_for_status_code():