Nobody tests their serializers. Be honest. You've got model specs, controller specs, maybe even some request specs if you're feeling virtuous — but serializers? They just sit there between your models and your API, quietly mangling data, and when they break the failure mode is not a 500 error but a subtly wrong JSON payload that breezes past every test you've written and blows up in a client's face on a Friday afternoon. Testing them in isolation catches that before you ruin your weekend.
So the brilliant minds behind ActiveModel::Serializer decided what we really needed was another abstraction. Because why have one layer when you can have two? Enter the Adapter. Now a serializer defines what gets serialized (the attributes on the model), while an adapter controls how it gets serialized. Want a flat JSON response for one endpoint and a JSONAPI-compliant envelope for another? Same serializer, different adapter. It's actually a decent idea, which is rare enough in the Ruby ecosystem that it deserves a standing ovation.
The catch — because there's always a catch — is that
ActionController::Serialization no longer
calls to_json on serializer objects directly.
That'd be too simple. Instead, serializers get wrapped in
an adapter via
ActiveModel::Serializer::Adapter.create,
which takes a serializer instance and returns whatever
adapter your config says you want
(ActiveModel::Serializer.config.adapter --
in my case, :json_api, because I enjoy pain).
Here's what this whole song and dance looks like when you actually fire up a console:
profile = Profile.first
# => #
serializer = ProfileSerializer.new(profile)
# => #, @root=false, @meta=nil, @meta_key=nil>
adapter = ActiveModel::Serializer::Adapter.create(serializer)
# => #, @root=true, @meta=nil, @meta_key=nil>, @options={}, @hash={}, @top={}, @fieldset=nil>
adapter.as_json
# => {:profiles=>{:id=>"liquid", :name=>"Liquid"}
So if you had nice, tidy serializer specs that just
called to_json and went home early —
congratulations, they're all lying to you now. You need
to go through the adapter layer to test the actual output
your API produces, or you're essentially testing a thing
that doesn't exist.
Right. So given a serializer like this:
class ProfileSerializer < ActiveModel::Serializer
attributes :id, :name
def id
object.permalink
end
end
Here's the spec that actually works. It wraps the serializer in an adapter — exactly the way Rails does it — and asserts against the resulting JSON. Full serialization pipeline, no controller, no routes, no mucking about. Just the truth:
require 'rails_helper'
RSpec.describe ProfileSerializer, :type => :serializer do
context 'Individual Resource Representation' do
let(:resource) { build(:profile) }
let(:serializer) { ProfileSerializer.new(resource) }
let(:serialization) { ActiveModel::Serializer::Adapter.create(serializer) }
subject do
# I'm using a JSONAPI adapter, which means my profile is wrapped in a
# top level `profiles` object.
JSON.parse(serialization.to_json)['profiles']
end
it 'has an id that matches #permalink' do
expect(subject['id']).to eql(resource.permalink)
end
it 'has a name' do
expect(subject['name']).to eql(resource.name)
end
end
end
Why bother? Because these specs are stupidly fast — we're talking milliseconds — and each one catches attribute-mapping bugs that controller tests gloss over. They'll scream at you the second someone adds a field to the model and conveniently forgets to expose it in the API. Which is everyone. Everyone forgets. For more on the adapter rewrite and its glorious indirection, see the ActiveModel::Serializers project on GitHub.
- Wrap serializers in
ActiveModel::Serializer::Adapter.createto test the actual output your API produces - Serializer specs run in milliseconds and catch attribute-mapping bugs that controller tests miss
- Always test through the adapter layer — calling
to_jsondirectly no longer matches what the controller sends