Chapter 3 — Contexts
Quick Recap
In the previous chapters, we crafted our first scenario focused on registering a new user:
import vedro
import httpx
from d42 import fake
from schemas.user import NewUserSchema
API_URL = "https://chat-api-tutorial.vedro.io/08jt6tbu88"
class Scenario(vedro.Scenario):
subject = "register new user"
def given_new_user(self):
self.user = fake(NewUserSchema)
def when_guest_registers(self):
self.response = httpx.post(f"{API_URL}/auth/register", json=self.user)
def then_it_should_return_success_response(self):
assert self.response.status_code == 200
def and_then_it_should_return_created_user(self):
assert self.response.json() == NewUserSchema % self.user
After creating an account, we can now move on to the next scenario — the user authentication process.
Setting the Stage for Authentication
Authentication, in our case, requires sending a POST /auth/login
request with username
and password
in the JSON body. At first glance, it might seem like we could use an approach similar to our registration scenario:
import vedro
import httpx
from d42 import fake
from schemas.user import NewUserSchema
API_URL = "https://chat-api-tutorial.vedro.io/mp6udequ9q"
class Scenario(vedro.Scenario):
subject = "login as registered user"
def given_user(self):
self.user = fake(NewUserSchema)
def when_user_logs_in(self):
self.response = httpx.post(f"{API_URL}/auth/login", json=self.user)
def then_it_should_return_success_response(self):
assert self.response.status_code == 200
However, this scenario failed, returning a 400 status code with an error message stating, "User does not exist". This error occurs because we attempted to authenticate a user who had not yet been registered. This highlights a critical point: we must first put our application in the right state before executing the primary action.
This is where the concept of contexts comes into play.
Understanding Vedro Contexts
A context in Vedro is essentially a function that helps set up the environment or state for our scenario. It ensures that all prerequisites for a given scenario are met before execution.
Let's see how to create a context.
# ./contexts/registered_user.py
import vedro
import httpx
API_URL = "https://chat-api-tutorial.vedro.io/ko3oegfdou"
@vedro.context
def registered_user(user):
response = httpx.post(f"{API_URL}/auth/register", json=user)
response.raise_for_status()
return
To efficiently manage contexts, they're typically stored in a contexts/
directory, with filenames matching their related context functions
With this context at our disposal, we can ensure the creation of a user before attempting to authenticate them. We use the context in the "given" step to register a user before trying to log them in. Here's how it looks in practice:
import vedro
import httpx
from d42 import fake
from schemas.user import NewUserSchema
from contexts.registered_user import registered_user
API_URL = "https://chat-api-tutorial.vedro.io/c3ehbwi6ie"
class Scenario(vedro.Scenario):
subject = "login as registered user"
def given_user(self):
self.user = fake(NewUserSchema)
registered_user(self.user)
def when_user_logs_in(self):
self.response = httpx.post(f"{API_URL}/auth/login", json=self.user)
def then_it_should_return_success_response(self):
assert self.response.status_code == 200
Now, the scenario passes as expected because the user is registered before we attempt to authenticate them.
The Power of Contexts
By incorporating the registered_user
context, we ensure that our scenario is truly atomic, meaning it tests exactly one thing – the user login. This way, we prevent our scenario from being polluted by the setup details of user registration.
Contexts not only allow us to set prerequisites for our scenarios but also provide reusability across different scenarios. For instance, any scenario that requires a registered user can now simply use this registered_user
context. This promotes modularity and reduces code duplication in our tests.
Final Touch: Dealing with Token
While our scenario for logging in as a registered user is now functioning correctly, there's an important aspect we still need to handle — the token. As part of the login process, the /auth/login
method returns a token, a key component in subsequent authenticated user actions. Therefore, validating this token in our scenario is a critical step.
First, we need to define a schema for this token:
# ./schemas/token.py
from d42 import schema
from .user import NewUserSchema
AuthTokenSchema = schema.dict({
"username": NewUserSchema["username"],
"token": schema.str.alphabet("0123456789abcdef").len(40),
"created_at": schema.int.min(0),
})
This schema has the following components:
- The
username
field should match the username defined in theNewUserSchema
. - The
token
field is a 40-character string composed of hexadecimal characters (0-9, a-f). - The
created_at
field is a positive integer representing a Unix timestamp.
(these specifications are based on the method documentation available at chat-api-tutorial.vedro.io/docs)
With this definition in place, we can now incorporate the schema into our scenario.
import vedro
import httpx
from d42 import fake
from schemas.user import NewUserSchema
from contexts.registered_user import registered_user
from schemas.token import AuthTokenSchema
API_URL = "https://chat-api-tutorial.vedro.io/zqxi8t2asb"
class Scenario(vedro.Scenario):
subject = "login as registered user"
def given_user(self):
self.user = fake(NewUserSchema)
registered_user(self.user)
def when_user_logs_in(self):
self.response = httpx.post(f"{API_URL}/auth/login", json=self.user)
def then_it_should_return_success_response(self):
assert self.response.status_code == 200
def and_it_should_return_created_token(self):
assert self.response.json() == AuthTokenSchema % {
"username": self.user["username"]
}
With this addition, our scenario now also verifies the structure of the response, ensuring that our API behaves as expected and returns data that adheres to the defined contract.
Summary
In summary, contexts in Vedro are a robust tool for managing prerequisite conditions, leading to clean, reliable, and maintainable tests. As we progress, we'll encounter more complex scenarios where contexts will truly shine.
In the next chapter, we will further refine our codebase by introducing the concept of interfaces, providing a unified approach to interacting with our application and reducing code repetition.