Upon hearing the term “linter” or “lint,” you likely form an idea of how this tool functions and its purpose.
You might envision Rubocop, which one Toptal developer maintains, or perhaps JSLint, ESLint, or even tools lesser-known or used.
This piece will introduce you to a different type of linter. These linters don’t examine code syntax or validate the Abstract-Syntax-Tree; instead, they focus on code verification. Their role is to confirm if an implementation aligns with a specific interface, going beyond lexical checks (like duck typing and standard interfaces) to sometimes include semantic verification.
To understand them better, let’s delve into some practical illustrations. If you’re not very familiar with Rails, a quick read of this might be helpful.
Let’s begin with a basic Lint example.
ActiveModel::Lint::Tests
official Rails documentation offers a detailed explanation of this Lint’s behavior:
“To test if an object adheres to the Active Model API, include ActiveModel::Lint::Tests in your TestCase. This incorporates tests indicating if your object is fully compliant or highlights any unimplemented API aspects. Remember, an object isn’t obligated to implement every API for compatibility with Action Pack. This module’s sole purpose is guidance if you desire all features out-of-the-box.”
Therefore, when implementing a class intended for use with existing Rails features like redirect_to or form_for, implementing a few methods is necessary. This functionality isn’t restricted to ActiveRecord objects. Your objects can utilize it too, but they must “quack” correctly.
Implementation
The implementation is quite straightforward. It involves a module designed for inclusion in test cases. The methods prefixed with test_ will be implemented by your framework. The @model instance variable is expected to be set up by the user before the test:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| module ActiveModel
module Lint
module Tests
def test_to_key
assert_respond_to model, :to_key
def model.persisted?() false end
assert model.to_key.nil?, "to_key should return nil when `persisted?` returns false"
end
def test_to_param
assert_respond_to model, :to_param
def model.to_key() [1] end
def model.persisted?() false end
assert model.to_param.nil?, "to_param should return nil when `persisted?` returns false"
end
...
private
def model
assert_respond_to @model, :to_model
@model.to_model
end
|
Usage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| class Person
def persisted?
false
end
def to_key
nil
end
def to_param
nil
end
# ...
end
# test/models/person_test.rb
require "test_helper"
class PersonTest < ActiveSupport::TestCase
include ActiveModel::Lint::Tests
setup do
@model = Person.new
end
end
|
ActiveModel::Serializer::Lint::Tests
While not new, Active model serializers offer ongoing learning opportunities. Incorporating ActiveModel::Serializer::Lint::Tests helps verify if an object conforms to the Active Model Serializers API. In case of non-compliance, the tests will pinpoint the missing elements.
However, the docs presents a crucial caveat: semantic correctness isn’t checked:
“These tests do not attempt to determine the semantic correctness of the returned values. For instance, you could implement serializable_hash to always return {}, and the tests would pass. It is up to you to ensure that the values are semantically meaningful.”
Essentially, only the interface’s structure is being examined. Let’s examine its implementation.
Implementation
This closely mirrors the ActiveModel::Lint::Tests implementation we encountered earlier, with slightly more rigor in certain aspects. It checks the arity or classes of returned values:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| module ActiveModel
class Serializer
module Lint
module Tests
# Passes if the object responds to <tt>read_attribute_for_serialization</tt>
# and if it requires one argument (the attribute to be read).
# Fails otherwise.
#
# <tt>read_attribute_for_serialization</tt> gets the attribute value for serialization
# Typically, it is implemented by including ActiveModel::Serialization.
def test_read_attribute_for_serialization
assert_respond_to resource, :read_attribute_for_serialization, 'The resource should respond to read_attribute_for_serialization'
actual_arity = resource.method(:read_attribute_for_serialization).arity
# using absolute value since arity is:
# 1 for def read_attribute_for_serialization(name); end
# -1 for alias :read_attribute_for_serialization :send
assert_equal 1, actual_arity.abs, "expected #{actual_arity.inspect}.abs to be 1 or -1"
end
# Passes if the object's class responds to <tt>model_name</tt> and if it
# is in an instance of +ActiveModel::Name+.
# Fails otherwise.
#
# <tt>model_name</tt> returns an ActiveModel::Name instance.
# It is used by the serializer to identify the object's type.
# It is not required unless caching is enabled.
def test_model_name
resource_class = resource.class
assert_respond_to resource_class, :model_name
assert_instance_of resource_class.model_name, ActiveModel::Name
end
...
|
Usage
Here’s an illustration of how ActiveModelSerializers utilizes the lint by integrating it into its test case:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| module ActiveModelSerializers
class ModelTest < ActiveSupport::TestCase
include ActiveModel::Serializer::Lint::Tests
setup do
@resource = ActiveModelSerializers::Model.new
end
def test_initialization_with_string_keys
klass = Class.new(ActiveModelSerializers::Model) do
attributes :key
end
value = 'value'
model_instance = klass.new('key' => value)
assert_equal model_instance.read_attribute_for_serialization(:key), value
end
|
Rack::Lint
Our previous examples disregarded semantics.
However, Rack::Lint operates quite differently. It functions as Rack middleware, enveloping your application. This middleware acts as a linter, verifying if requests and responses adhere to the Rack specification. It proves useful when implementing a Rack server like Puma to serve the Rack application, ensuring adherence to the Rack specification.
Alternatively, it’s employed when building a barebones application to prevent basic HTTP protocol errors.
Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| module Rack
class Lint
def initialize(app)
@app = app
@content_length = nil
end
def call(env = nil)
dup._call(env)
end
def _call(env)
raise LintError, "No env given" unless env
check_env env
env[RACK_INPUT] = InputWrapper.new(env[RACK_INPUT])
env[RACK_ERRORS] = ErrorWrapper.new(env[RACK_ERRORS])
ary = @app.call(env)
raise LintError, "response is not an Array, but #{ary.class}" unless ary.kind_of? Array
raise LintError, "response array has #{ary.size} elements instead of 3" unless ary.size == 3
status, headers, @body = ary
check_status status
check_headers headers
hijack_proc = check_hijack_response headers, env
if hijack_proc && headers.is_a?(Hash)
headers[RACK_HIJACK] = hijack_proc
end
check_content_type status, headers
check_content_length status, headers
@head_request = env[REQUEST_METHOD] == HEAD
[status, headers, self]
end
## === The Content-Type
def check_content_type(status, headers)
headers.each { |key, value|
## There must not be a <tt>Content-Type</tt>, when the +Status+ is 1xx, 204 or 304.
if key.downcase == "content-type"
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
raise LintError, "Content-Type header found in #{status} response, not allowed"
end
return
end
}
end
## === The Content-Length
def check_content_length(status, headers)
headers.each { |key, value|
if key.downcase == 'content-length'
## There must not be a <tt>Content-Length</tt> header when the +Status+ is 1xx, 204 or 304.
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key? status.to_i
raise LintError, "Content-Length header found in #{status} response, not allowed"
end
@content_length = value
end
}
end
...
|
Usage in Your App
Imagine constructing a very basic endpoint. While it should sometimes respond with “No Content,” a deliberate error causes it to send content in 50% of cases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # foo.rb
# run with rackup foo.rb
Foo = Rack::Builder.new do
use Rack::Lint
use Rack::ContentLength
app = proc do |env|
if rand > 0.5
no_content = Rack::Utils::HTTP_STATUS_CODES.invert['No Content']
[no_content, { 'Content-Type' => 'text/plain' }, ['bummer no content with content']]
else
ok = Rack::Utils::HTTP_STATUS_CODES.invert['OK']
[ok, { 'Content-Type' => 'text/plain' }, ['good']]
end
end
run app
end.to_app
|
Rack::Lint intervenes in such scenarios, intercepting the response, verifying it, and raising an exception:
1
2
3
| Rack::Lint::LintError: Content-Type header found in 204 response, not allowed
/Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:21:in `assert'
/Users/dev/.rbenv/versions/2.6.5/lib/ruby/gems/2.6.0/gems/rack-2.2.3/lib/rack/lint.rb:710:in `block in check_content_type'
|
Usage in Puma
This example demonstrates Puma wrapping a simple application lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] } first within ServerLint (inheriting from Rack::Lint) and then within ErrorChecker.
If the specification is violated, the lint throws exceptions. The checker captures these exceptions and returns a 500 error code. The test code confirms that no exception occurred:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| class TestRackServer < Minitest::Test
class ErrorChecker
def initialize(app)
@app = app
@exception = nil
end
attr_reader :exception, :env
def call(env)
begin
@app.call(env)
rescue Exception => e
@exception = e
[ 500, {}, ["Error detected"] ]
end
end
end
class ServerLint < Rack::Lint
def call(env)
check_env env
@app.call(env)
end
end
def setup
@simple = lambda { |env| [200, { "X-Header" => "Works" }, ["Hello"]] }
@server = Puma::Server.new @simple
port = (@server.add_tcp_listener "127.0.0.1", 0).addr[1]
@tcp = "http://127.0.0.1:#{port}"
@stopped = false
end
def test_lint
@checker = ErrorChecker.new ServerLint.new(@simple)
@server.app = @checker
@server.run
hit(["#{@tcp}/test"])
stop
refute @checker.exception, "Checker raised exception"
end
|
This process certifies Puma as Rack compatible.
RailsEventStore - Repository Lint
Rails Event Store is a library designed for the publication, consumption, storage, and retrieval of events. Its purpose is to facilitate the implementation of Event-Driven Architecture in Rails applications. This modular library consists of smaller components like repository, mapper, dispatcher, scheduler, subscriptions, and serializer, each allowing interchangeable implementations.
For instance, the default repository utilizes ActiveRecord, assuming a specific table structure for event storage. Conversely, your implementation might employ ROM or function in-memory without event storage, proving beneficial during testing.
But how can you ascertain if your component’s behavior aligns with the library’s expectations? Through the provided linter, of course. And it’s comprehensive, covering around 80 cases. Some are relatively simple:
1
2
3
4
5
6
| specify 'adds an initial event to a new stream' do
repository.append_to_stream([event = SRecord.new], stream, version_none)
expect(read_events_forward(repository).first).to eq(event)
expect(read_events_forward(repository, stream).first).to eq(event)
expect(read_events_forward(repository, stream_other)).to be_empty
end
|
While others are more intricate, involving unhappy paths:
1
2
3
4
5
6
7
8
9
10
| it 'does not allow linking same event twice in a stream' do
repository.append_to_stream(
[SRecord.new(event_id: "a1b49edb")],
stream,
version_none
).link_to_stream(["a1b49edb"], stream_flow, version_none)
expect do
repository.link_to_stream(["a1b49edb"], stream_flow, version_0)
end.to raise_error(EventDuplicatedInStream)
end
|
At nearly 1,400 lines of Ruby code, it’s arguably the largest Ruby linter. However, if you’re aware of a larger one, let me know. Notably, it focuses entirely on semantics.
While it extensively tests the interface too, considering the article’s scope, I’d deem that an afterthought.
Implementation
The repository linter leverages the RSpec Shared Examples functionality:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| module RubyEventStore
::RSpec.shared_examples :event_repository do
let(:helper) { EventRepositoryHelper.new }
let(:specification) { Specification.new(SpecificationReader.new(repository, Mappers::NullMapper.new)) }
let(:global_stream) { Stream.new(GLOBAL_STREAM) }
let(:stream) { Stream.new(SecureRandom.uuid) }
let(:stream_flow) { Stream.new('flow') }
# ...
it 'just created is empty' do
expect(read_events_forward(repository)).to be_empty
end
specify 'append_to_stream returns self' do
repository
.append_to_stream([event = SRecord.new], stream, version_none)
.append_to_stream([event = SRecord.new], stream, version_0)
end
# ...
|
Usage
Similar to others, this linter expects you to furnish certain methods, primarily repository, which returns the implementation requiring verification. Test examples are integrated using RSpec’s built-in include_examples method:
1
2
3
4
| RSpec.describe EventRepository do
include_examples :event_repository
let(:repository) { EventRepository.new(serializer: YAML) }
end
|
Wrapping Up
Evidently, “linter” encompasses a broader meaning than typically perceived. Whenever you develop a library anticipating interchangeable collaborators, consider providing a linter.
Even if your library’s class is the sole entity passing such tests initially, it signifies your commitment to extensibility as a software engineer. It also compels you to consciously, rather than accidentally, contemplate the interface of each component in your code.
Resources