diff --git a/test/test_controller.rb b/test/test_controller.rb
new file mode 100644
index 0000000..a41b9bd
--- /dev/null
+++ b/test/test_controller.rb
@@ -0,0 +1,116 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+require 'tmpdir'
+require 'fileutils'
+
+class ControllerTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ @views_dir = Dir.mktmpdir('prism-controller-views')
+ write_views
+ define_controller
+ define_app
+ end
+
+ def teardown
+ FileUtils.remove_entry(@views_dir)
+ %i[PostsController PostsApp].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) }
+ end
+
+ def write_views
+ FileUtils.mkdir_p(File.join(@views_dir, 'posts'))
+ FileUtils.mkdir_p(File.join(@views_dir, 'layouts'))
+ File.write(File.join(@views_dir, 'layouts', 'application.erb'), <<~ERB)
+
<%== content %>
+ ERB
+ File.write(File.join(@views_dir, 'posts', 'index.erb'), <<~ERB)
+ <% posts.each do |title| %><%== partial('post', locals: { title: title }) %><% end %>
+ ERB
+ File.write(File.join(@views_dir, 'posts', '_post.erb'), '<%= title %>')
+ File.write(File.join(@views_dir, 'posts', 'show.erb'), '<%= id %>
')
+ end
+
+ def define_controller
+ views_dir = @views_dir
+ controller_class = Class.new(Prism::Controller) do
+ layout :application
+ views_root views_dir
+
+ before_action :set_common, only: %i[index show]
+ after_action :mark_served
+
+ def self.served_log
+ @served_log ||= []
+ end
+
+ def set_common
+ @posts = ['first post', 'second post']
+ end
+
+ def index
+ render(:index, locals: { posts: @posts })
+ end
+
+ def show
+ render(:show, layout: nil, locals: { id: params['id'] })
+ end
+
+ def create
+ halt(201, JSON.generate(created: params['title']))
+ end
+
+ def mark_served
+ self.class.served_log << action_name
+ end
+ end
+ Object.const_set(:PostsController, controller_class)
+ end
+
+ def define_app
+ app_class = Class.new(Prism::Application) do
+ get '/posts', to: 'posts#index', as: :posts
+ get '/posts/:id', to: 'posts#show', as: :post
+ post '/posts', to: 'posts#create'
+ end
+ Object.const_set(:PostsApp, app_class)
+ end
+
+ def call(method, path, **opts)
+ PostsApp.call(env_for(method, path, **opts))
+ end
+
+ def test_index_renders_with_layout_and_partials
+ _, _, body = call('GET', '/posts')
+ html = body.join
+ assert_includes html, ''
+ assert_includes html, 'first post'
+ assert_includes html, 'second post'
+ end
+
+ def test_show_renders_action_template
+ _, _, body = call('GET', '/posts/42')
+ assert_equal '42
', body.join
+ end
+
+ def test_before_action_runs_with_only_filter
+ PostsController.served_log.clear
+ call('GET', '/posts')
+ call('GET', '/posts/1')
+ call('POST', '/posts')
+ assert_equal %w[index show create], PostsController.served_log
+ end
+
+ def test_create_action_uses_halt
+ status, _, body = call('POST', '/posts', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'title=hello')
+ assert_equal 201, status
+ assert_equal 'hello', JSON.parse(body.join)['created']
+ end
+
+ def test_named_route_url_for
+ status, _, body = call('GET', '/posts/9')
+ assert_equal 200, status
+ assert_equal PostsApp.router.path_to(:post, id: 9), '/posts/9'
+ end
+end
diff --git a/test/test_controller_filters.rb b/test/test_controller_filters.rb
new file mode 100644
index 0000000..812d492
--- /dev/null
+++ b/test/test_controller_filters.rb
@@ -0,0 +1,86 @@
+require 'minitest/autorun'
+require 'prism'
+require 'support'
+
+class FilterController < Prism::Controller
+ before_action(only: %i[index]) { @log = (@log || []) << 'inline-before' }
+ before_action :require_token, except: %i[public_action]
+ after_action :log_after, except: %i[public_action]
+
+ def self.after_log
+ @after_log ||= []
+ end
+
+ def index
+ json(log: @log)
+ end
+
+ def secret
+ json(reached: true)
+ end
+
+ def public_action
+ json(reached: true)
+ end
+
+ private
+
+ def require_token
+ halt(401, 'no token') unless params['token'] == 'ok'
+ end
+
+ def log_after
+ self.class.after_log << action_name
+ end
+end
+
+class FilterApp < Prism::Application
+ get '/index', to: 'filter#index'
+ get '/secret', to: 'filter#secret'
+ get '/public', to: 'filter#public_action'
+end
+
+class ControllerFiltersTest < Minitest::Test
+ include TestSupport
+
+ def setup
+ FilterController.after_log.clear
+ end
+
+ def call(method, path, **opts)
+ FilterApp.call(env_for(method, path, **opts))
+ end
+
+ def test_inline_before_action_block_runs_and_scoped_to_only
+ _, _, body = call('GET', '/index?token=ok')
+ assert_equal ['inline-before'], JSON.parse(body.join)['log']
+ end
+
+ def test_before_action_with_except_still_guards_other_actions
+ status, _, body = call('GET', '/secret')
+ assert_equal 401, status
+ assert_equal 'no token', body.join
+ end
+
+ def test_before_action_except_skips_the_named_action
+ status, _, body = call('GET', '/public')
+ assert_equal 200, status
+ assert JSON.parse(body.join)['reached']
+ end
+
+ def test_halted_before_action_prevents_the_action_body_from_running
+ status, _, body = call('GET', '/secret')
+ refute_equal 200, status
+ refute_includes body.join, 'reached'
+ end
+
+ def test_after_action_runs_when_before_action_passes
+ call('GET', '/secret?token=ok')
+ assert_includes FilterController.after_log, 'secret'
+ end
+
+ def test_after_action_except_is_honored
+ call('GET', '/public')
+ refute_includes FilterController.after_log, 'public_action'
+ end
+end