Diagnostics
After this page you can read the report of a failed test, log every call of a
passing run, keep secrets out of reports, and repeat a failed call with curl.
The examples on this page use this configuration. Every report below comes from a real run of the files that are shown above it.
[pytest]
gql_url = http://localhost:8000/graphql
Read a failure section
When a test fails, pytest adds a section to its report. The section is called
GraphQL calls. It lists the calls that the test made, so you do not need to
add print() calls and run the test again.
This test fails on purpose. A user that does not exist cannot be renamed. The documentation tests do not run this block as a normal example, because a passing test is what they look for. They run it in a separate check and compare the report below with the real output, line by line.
import pytest
@pytest.fixture
def gql_auth():
return "token-PLACEHOLDER"
@pytest.fixture
def gql_headers():
return {"X-Session": "session-PLACEHOLDER"}
def test_rename_a_user_that_does_not_exist(gql):
gql.query("user", id="u1", fields=["id", "name"])
gql.mutation(
"updateUser", id="secret-PLACEHOLDER", name="Grace", fields=["id", "name"]
)
pytest test_rename.py
Pytest prints the traceback first. The section follows it. The times in your run will differ. This is the section:
------------------------------ GraphQL calls (2) -------------------------------
[1] query user 200 1ms
query user($id: ID!) { user(id: $id) { id name } }
variables: {"id": "u1"}
data: {"user": {"id": "u1", "name": "Ada Lovelace"}}
[2] mutation updateUser 200 1ms <-- FAILED HERE
mutation updateUser($id: ID!, $name: String) { updateUser(id: $id, name: $name) { id name } }
variables: {"id": "secret-PLACEHOLDER", "name": "Grace"}
errors:
- (no code) at ["updateUser"]: 'secret-PLACEHOLDER'
reproduce:
curl -sS -X POST http://localhost:8000/graphql -H 'X-Session: session-PLACEHOLDER' -H 'Authorization: '"${PYTEST_GQL_HEADER_AUTHORIZATION}" --data '{"operationName": "updateUser", "query": "mutation updateUser($id: ID!, $name: String) {\n updateUser(id: $id, name: $name) {\n id\n name\n }\n}", "variables": {"id": "secret-PLACEHOLDER", "name": "Grace"}}'
The title shows how many calls the test made. A call has a heading and some lines under it. A line is shown only when the call has something to put in it.
- The heading is
[number] kind operation status time. The kind isqueryormutation. The status is the HTTP status, or-when no answer came. The time is in milliseconds. FAILED HEREmarks the last call that the test made. That call is the nearest to the failure. The section lists every call, so a test that failed on the result of an earlier call still shows it.- The document is on one line. The
curlline has the exact text. variablesshows the variables as JSON.skippedlists the fields that auto-selection left out, with the reason. See Selections. These tests usedfields=, so there are none.datashows the data of the answer, cut when it is long. A cut ends with(truncated, N fields).errorsshows the first errors of the answer, one line each. The server's text is in the line, after the code and the path. A message that has line breaks continues under its own entry.failureappears when the call raised before any answer came, for example a connection error.reproduceis acurlcommand. Only the last call has one.
The client keeps the last 50 calls of a test. If it dropped older ones, the title
reads GraphQL calls (last 50 of 73), and the numbers continue from the real
count. --show-capture=no hides the section, as it hides every captured section.
Log every call
The failure section covers only failed tests. To see the calls of tests that pass,
use --gql-log. Each call is written to the logger pytest_graphql.calls at
level INFO. Pytest shows the log of a test that fails. To see all of it as the
run goes, ask pytest to show live logs:
def test_users_are_listed(gql):
users = gql.query("users", fields=["id", "name"])
assert len(users) == 3
def test_a_user_can_be_renamed(gql):
user = gql.mutation("updateUser", id="u1", name="Ada King", fields=["id", "name"])
assert user.name == "Ada King"
pytest test_users.py --gql-log -o log_cli=true -o log_cli_level=INFO -o log_cli_format="%(message)s"
The log lines are:
query users POST http://localhost:8000/graphql -> ok (status 200, 0.4 ms, 0 error(s))
mutation updateUser POST http://localhost:8000/graphql -> ok (status 200, 0.7 ms, 0 error(s))
A line says the kind, the operation, the method, the URL, the outcome, the status,
the time, and how many errors the answer had. The outcome is ok, errors or
failed.
--gql-log-level full adds the lines of the failure section for each call, without
the heading, the marker and the reproduce: line:
pytest test_users.py::test_a_user_can_be_renamed --gql-log --gql-log-level full -o log_cli=true -o log_cli_level=INFO -o log_cli_format="%(message)s"
mutation updateUser POST http://localhost:8000/graphql -> ok (status 200, 0.7 ms, 0 error(s))
mutation updateUser($id: ID!, $name: String) { updateUser(id: $id, name: $name) { id name } }
variables: {"id": "u1", "name": "Ada King"}
data: {"updateUser": {"id": "u1", "name": "Ada King"}}
The log goes through the same redaction as the failure section, which the next
section describes. The plugin sets the
level of its logger for the session and restores it after. What pytest shows is
decided by the logging options of pytest, so you can send it to a file with
--log-file and --log-file-level=INFO.
Keep secrets out of the report
A report is read by many people and is often saved by CI. So the section hides every value that it can recognize as a secret. It recognizes a value by the name of the header or the variable that carries it, and by having seen the value before. A secret under a name that no rule lists is not recognized, and the report shows it. Before you share reports, list the name of every header and variable that holds a secret.
Look at the first report again. The test sent a token in the Authorization
header, and it is not in the report. The curl line has a shell variable in its
place.
Three kinds of values are hidden:
- Header values. Names in
redact_headersare hidden. The default names areauthorization,cookie,proxy-authorizationandx-api-key. - Variables. Patterns in
redact_variableshide a variable at any depth, and the same patterns hide a field of the answer with that name. The default patterns arepassword,token,secret,api_key,access_token,refresh_token,authorization,otp,pin,credit_cardandssn. A pattern is a name or a dotted path, and it can use*. - Known values in free text. A server can repeat a secret in an error message. A value that the client knows to be a secret is removed from every text that comes from outside, if it has 8 or more characters. The value counts as known when it is the value of a hidden header, of a hidden variable, a cookie, or a part of the URL.
The first report shows that a name that is not listed is not hidden. X-Session
carries a session, but it is not in the default list, so its value is in the
curl line. In the same way, the test sent id="secret-PLACEHOLDER" and the
server repeated it in its error message. Neither id nor X-Session is secret by
default. Here the example treats both as secrets. Add the header name in the ini
file, and the variable name in gql_config:
[pytest]
gql_url = http://localhost:8000/graphql
gql_redact_headers = X-Session
import dataclasses
import pytest
@pytest.fixture(scope="session")
def gql_config(gql_config):
names = (*gql_config.redact_variables, "id")
return dataclasses.replace(gql_config, redact_variables=names)
The option gql_redact_headers adds names to the defaults. A list that you give to
ClientConfig.redact_headers replaces them, so include the default names there.
There is no ini option for variable patterns. Set redact_variables in
gql_config, as shown.
Run the failing test again:
pytest test_rename.py
------------------------------ GraphQL calls (2) -------------------------------
[1] query user 200 1ms
query user($id: ID!) { user(id: $id) { id name } }
variables: {"id": "[redacted:id]"}
data: {"user": {"id": "[redacted]", "name": "Ada Lovelace"}}
[2] mutation updateUser 200 1ms <-- FAILED HERE
mutation updateUser($id: ID!, $name: String) { updateUser(id: $id, name: $name) { id name } }
variables: {"id": "[redacted:id]", "name": "Grace"}
errors:
- (no code) at ["updateUser"]: '[redacted:id]'
reproduce:
curl -sS -X POST http://localhost:8000/graphql -H 'X-Session: '"${PYTEST_GQL_HEADER_X_2D_SESSION}" -H 'Authorization: '"${PYTEST_GQL_HEADER_AUTHORIZATION}" --data '{"operationName": "updateUser", "query": "mutation updateUser($id: ID!, $name: String) {\n updateUser(id: $id, name: $name) {\n id\n name\n }\n}", "variables": {"id": "[redacted:id]", "name": "Grace"}}'
The variable is a marker with its name. The id field of the answer is a bare
[redacted]. The server's error message holds the secret too, and there it is
replaced by the same marker. The X-Session header is a shell variable now.
There are limits that you should know:
- A secret that no rule covers is not hidden. List the name of every header and variable that holds one.
- A known value that is shorter than
min_redacted_value_length(8 by default) is not removed from free text, because it would match ordinary words. Give test accounts credentials that are at least that long. redact_values=Falseturns the removal from free text off. Only the listed names are hidden then.- When the client cannot be sure that a text is free of a secret, it shows
[withheld: this text would show a redacted value]instead of the text. This includes the message of an exception. A withheld text means that a hidden value came back somewhere you did not expect. - The URL has no user name, no password and no query string in a report. A cookie is shown by name only.
The RequestInfo that Auth and middleware receive holds the real values,
because they need them. Do not print it or write it to a log. Use
request.redacted(), and list every header name that carries a secret, because
a header with another name shows its value. See Extending.
Reproduce a call with curl
The reproduce: line of the report is a curl command. It posts the same
document and variables to the same URL. A header with a hidden value is written as
a shell variable. The name of the variable is PYTEST_GQL_HEADER_ and the header
name in capital letters. A character that is not a letter or a digit becomes an
underscore, the hexadecimal code of the character, and an underscore. So
X-Session becomes X_2D_SESSION, and Authorization stays AUTHORIZATION.
Put the real value in the shell, and paste the command:
export PYTEST_GQL_HEADER_AUTHORIZATION='Bearer <your token>'
export PYTEST_GQL_HEADER_X_2D_SESSION='<your session>'
curl -sS -X POST http://localhost:8000/graphql ...
The command comes from the call as the client sent it, after Auth and middleware
changed it. A call that is not in a report, for example in a script, is on the
recorder of the client. Each recorded call keeps its curl text.
response = gql.query("user", id="u1", fields=["id", "name"], raw=True)
command = gql.recorder.calls[-1].curl
assert command.startswith(f"curl -sS -X POST {response.request.url}")
assert '"variables": {"id": "u1"}' in command
The header and the summary
The plugin prints two lines at the start of a run, and a GraphQL section at the
end. A line of three dots stands for the lines that pytest prints between them.
pytest test_users.py
graphql: endpoint http://localhost:8000/graphql, seed 0, run id 444136ffab844da6b11687476182758a
graphql: schema loads when the first test needs it, and is listed at the end of the run
...
=================================== GraphQL ====================================
schema main: introspection:endpoint, 26 types, 16 queries, 3 mutations, 0 subscriptions, loaded in 0.016s
The endpoint has no user name and no query string. The seed is the one that
gql.fake uses. The run id is the one that unique() values carry.
The schema loads when the first test needs it, so its facts come at the end of the
run. There is one line for each process that loaded a schema. Outside pytest-xdist
the process is main. With -n, each worker has a line, and a last line says how
many workers loaded the schema. See Cookbook. The load time is on
the line, so a slow start has an explanation. --gql-show-schema-stats adds the
fingerprint, the number of types of each kind, the number of fields and the load
time.
Add your own section
A project can add text to the failure report of a test, for example the state of
a database row. That is the hook pytest_graphql_report_section. See
Extending.