Ltix.Test provides helpers for testing your LTI application.
Set up a simulated platform in one call, then test your controllers,
authorization logic, and role-based behavior without a real LMS.
Setup
Add your storage adapter and the Req test plug to config/test.exs.
# config/test.exs
config :ltix,
storage_adapter: MyApp.LtiStorageAdapter,
req_options: [plug: {Req.Test, :ltix}]The :ltix atom is a sentinel. Each internal module rewrites it to its
own stub name so that HTTP calls are isolated per service.
setup_platform!/1 automatically stubs JWKS fetches.
Then create a test platform in your setup block. Pass a :registration
function to create matching records in your own persistence layer. The
function receives an Ltix.Registration with the platform details
(issuer, client_id, endpoints) and returns your app's struct.
defmodule MyAppWeb.LtiControllerTest do
use MyAppWeb.ConnCase, async: true
setup do
jwk = MyApp.Lti.create_jwk!()
platform = Ltix.Test.setup_platform!(
registration: fn reg ->
MyApp.Lti.create_registration!(%{
issuer: reg.issuer,
client_id: reg.client_id,
auth_endpoint: reg.auth_endpoint,
jwks_uri: reg.jwks_uri,
token_endpoint: reg.token_endpoint,
jwk_id: jwk.id
})
end,
tool_jwk: Ltix.JWK.new!(private_key_pem: jwk.private_key_pem, kid: jwk.kid)
)
%{platform: platform}
end
endPass the key your storage adapter signs with as :tool_jwk, so Ltix.Test.verify_deep_linking_response/2 can verify what your tool signs.
If your storage adapter auto-creates deployments (common pattern), you
don't need a deployment factory. Otherwise, pass a 2-arity
:deployment function that receives (Ltix.Deployment, your_registration).
Without a storage adapter of your own, use the in-memory Ltix.Test.StorageAdapter. Set it as storage_adapter in config/test.exs, then start it in your setup with your test platforms.
setup do
platform = Ltix.Test.setup_platform!()
{:ok, pid} = Ltix.Test.StorageAdapter.start_link(platforms: [platform])
Ltix.Test.StorageAdapter.set_pid(pid)
%{platform: platform}
endChecking your storage adapter
Ltix.Test.assert_storage_adapter!/2 checks your real adapter's registration and deployment lookups, protocol conversions, nonce replay prevention, and signatures. See Testing your adapter for factories and database sandbox setup.
Testing your controller
Simulate a full platform-initiated launch against your controller endpoints. This tests your routes, session handling, and response logic end-to-end.
test "instructor launch renders the dashboard", %{conn: conn, platform: platform} do
# Platform initiates login
conn =
conn
|> post(~p"/lti/login", Ltix.Test.login_params(platform))
# Follow the redirect back to your launch endpoint
assert redirected_to(conn, 302) =~ "https://platform.example.com/auth"
state = get_session(conn, :lti_state)
redirect_uri = redirected_to(conn, 302)
nonce = Ltix.Test.extract_nonce(redirect_uri)
conn =
conn
|> recycle()
|> Plug.Test.init_test_session(%{lti_state: state})
|> post(
~p"/lti/launch",
Ltix.Test.launch_params(platform,
nonce: nonce,
state: state,
roles: [:instructor],
name: "Jane Doe"
)
)
assert html_response(conn, 200) =~ "Dashboard"
assert html_response(conn, 200) =~ "Jane Doe"
end
test "learner launch renders the assignment view", %{conn: conn, platform: platform} do
conn = post(conn, ~p"/lti/login", Ltix.Test.login_params(platform))
state = get_session(conn, :lti_state)
nonce = Ltix.Test.extract_nonce(redirected_to(conn, 302))
conn =
conn
|> recycle()
|> Plug.Test.init_test_session(%{lti_state: state})
|> post(
~p"/lti/launch",
Ltix.Test.launch_params(platform,
nonce: nonce,
state: state,
roles: [:learner],
context: %{id: "course-1", title: "Elixir 101"}
)
)
assert html_response(conn, 200) =~ "Elixir 101"
refute html_response(conn, 200) =~ "Grade"
endTesting business logic
When testing code that receives a %LaunchContext{}, skip the OIDC
flow entirely with build_launch_context/2. This is faster and isolates
your logic from controller and HTTP concerns.
defmodule MyApp.PermissionsTest do
use ExUnit.Case, async: true
setup do
%{platform: Ltix.Test.setup_platform!()}
end
test "instructors can manage grades", %{platform: platform} do
context = Ltix.Test.build_launch_context(platform,
roles: [:instructor],
name: "Jane Doe"
)
assert MyApp.Permissions.can_manage_grades?(context)
end
test "TAs can view but not manage grades", %{platform: platform} do
alias Ltix.LaunchClaims.Role
context = Ltix.Test.build_launch_context(platform,
roles: [%Role{type: :context, name: :instructor, sub_role: :teaching_assistant}]
)
assert MyApp.Permissions.can_view_grades?(context)
refute MyApp.Permissions.can_manage_grades?(context)
end
test "learners see their own submissions only", %{platform: platform} do
context = Ltix.Test.build_launch_context(platform,
roles: [:learner],
subject: "student-42",
context: %{id: "course-1"}
)
submissions = MyApp.Submissions.list_for(context)
assert Enum.all?(submissions, &(&1.user_id == "student-42"))
end
endCustomizing launches
launch_params/2 and build_launch_context/2 take the same launch options with the same defaults, so a launch built either way has the same claims. Without options, the launch is a resource link launch by an instructor with subject "user-12345" in context "context-001".
Roles
Pass atoms for common LIS context roles.
roles: [:instructor, :learner]For sub-roles, pass a %Role{} struct.
alias Ltix.LaunchClaims.Role
roles: [%Role{type: :context, name: :instructor, sub_role: :teaching_assistant}]For institution or system roles, or custom role URIs, pass the full URI
string. URIs Ltix does not recognize are listed in
context.claims.unrecognized_roles, as in a real launch.
roles: ["http://purl.imsglobal.org/vocab/lis/v2/institution/person#Faculty"]Context and resource link
Ltix.Test.build_launch_context(platform,
roles: [:instructor],
context: %{
id: "course-1",
label: "CS101",
title: "Intro to CS",
type: ["http://purl.imsglobal.org/vocab/lis/v2/course#CourseOffering"]
},
resource_link: %{id: "assignment-1", title: "Quiz 1"}
)Both options also take the %Ltix.LaunchClaims.Context{} and %Ltix.LaunchClaims.ResourceLink{} structs.
Deep linking
Pass message_type: :deep_linking. Settings you leave out accept the five standard content types, every presentation target, and multiple items.
Ltix.Test.build_launch_context(platform,
message_type: :deep_linking,
deep_linking_settings: %{accept_types: ["ltiResourceLink"], accept_line_item: true}
)Anonymous launches
Pass subject: nil for a launch without a user identifier.
Ltix.Test.launch_params(platform, nonce: nonce, state: state, subject: nil)Ltix.handle_callback/3 rejects such a launch unless your app accepts anonymous launches. Pass allow_anonymous: true to the call, or set it for the whole app with config :ltix, allow_anonymous: true.
Ltix.handle_callback(params, state, allow_anonymous: true)build_launch_context/2 builds an anonymous context without either.
Service endpoints
Give the launch grade and memberships service endpoints as maps of the endpoint struct fields, or the structs themselves. The grade service endpoint needs its scopes, and the memberships endpoint can be just its URL.
Ltix.Test.launch_params(platform,
nonce: nonce,
state: state,
ags_endpoint: %{
line_items: "https://platform.example.com/lineitems",
scopes: ["https://purl.imsglobal.org/spec/lti-ags/scope/lineitem"]
},
memberships_endpoint: "https://platform.example.com/memberships"
)Raw claim overrides
For claims not covered by the convenience options, use :claims to
merge raw claims over the built ones.
Ltix.Test.launch_params(platform,
nonce: nonce,
state: state,
claims: %{
"https://purl.imsglobal.org/spec/lti/claim/custom" => %{
"canvas_course_id" => "12345"
}
}
)Hand-built tokens
To test how your app handles a token launch_params/2 doesn't build, start from valid_lti_claims/2, change the claims, and sign them with the platform's key.
claims =
platform
|> Ltix.Test.valid_lti_claims(%{"nonce" => nonce})
|> Map.delete("sub")
params = %{"id_token" => Ltix.Test.sign_id_token(claims, platform.jwk), "state" => state}Testing advantage services
When your application uses Ltix.GradeService or
Ltix.MembershipsService, you need to stub both the OAuth token
request and the service's HTTP calls.
Grade service
test "submits a score after launch", %{platform: platform} do
Ltix.Test.stub_token_response(scopes: [
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
])
Ltix.Test.stub_post_score()
context = Ltix.Test.build_launch_context(platform,
ags_endpoint: %{
line_item: "https://platform.example.com/lineitems/1",
scopes: ["https://purl.imsglobal.org/spec/lti-ags/scope/score"]
}
)
{:ok, client} = Ltix.GradeService.authenticate(context)
{:ok, score} = Ltix.GradeService.Score.new(
user_id: "student-42",
score_given: 85,
score_maximum: 100,
activity_progress: :completed,
grading_progress: :fully_graded
)
assert :ok = Ltix.GradeService.post_score(client, score)
endMemberships service
test "fetches roster", %{platform: platform} do
alias Ltix.LaunchClaims.Role
alias Ltix.MembershipsService.Member
Ltix.Test.stub_token_response(scopes: [
"https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly"
])
Ltix.Test.stub_list_members(
[%Member{user_id: "student-1", roles: [Role.from_atom(:learner)], name: "Alice"}],
context: %{id: "course-1", title: "Elixir 101"}
)
context = Ltix.Test.build_launch_context(platform,
memberships_endpoint: "https://platform.example.com/memberships"
)
{:ok, client} = Ltix.MembershipsService.authenticate(context)
{:ok, roster} = Ltix.MembershipsService.list_members(client)
assert length(roster.members) == 1
assert hd(roster.members).name == "Alice"
endSeveral grade operations
Each stub handles one operation, so stub every operation your code calls. Stubbing an operation again replaces its stub. A request without a matching stub fails the test with its method and URL.
test "creates a line item and posts a score to it", %{platform: platform} do
alias Ltix.GradeService.LineItem
Ltix.Test.stub_token_response()
Ltix.Test.stub_create_line_item(
%LineItem{id: "https://lms.example.com/lineitems/9", label: "Quiz 1", score_maximum: 100}
)
Ltix.Test.stub_post_score()
context = Ltix.Test.build_launch_context(platform,
ags_endpoint: %{
line_items: "https://lms.example.com/lineitems",
scopes: [
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
]
}
)
{:ok, client} = Ltix.GradeService.authenticate(context)
{:ok, item} = Ltix.GradeService.create_line_item(client, label: "Quiz 1", score_maximum: 100)
score =
Ltix.GradeService.Score.new!(
user_id: "student-42",
score_given: 85,
score_maximum: 100,
activity_progress: :completed,
grading_progress: :fully_graded
)
assert :ok = Ltix.GradeService.post_score(client, score, line_item: item)
endTo return different data per line item, pass :line_item to the per-line-item stubs. A stub without it handles every line item the others do not.
Ltix.Test.stub_list_line_items([quiz, exam])
Ltix.Test.stub_list_results(quiz_results, line_item: quiz)
Ltix.Test.stub_list_results(exam_results, line_item: exam)The list stubs handle list_* and stream_* calls, one page at a time when the call passes :per_page.
Stubs apply to the test process and to any process you allow with Req.Test.allow(Ltix.GradeService, self(), pid), so they work in async tests and in processes your code starts.
Per-operation stubs accept the struct(s) the platform would return. For
custom response logic (inspecting headers, returning errors, etc.), use
Req.Test.stub(Ltix.GradeService, fn conn -> ... end) directly. It
replaces every Ltix.Test stub of that service.