---
title: Why Duck Typer?
teaser: 'Some say interface tests are fragile and shouldn''t be written. I disagree.
  Here''s why I think they''re worth writing.

  '
tags: ruby,testing,open source,design
author: Thiago Araújo Silva
published_on: 2026-05-26
---

[Duck Typer][duck-typer] is a Ruby gem that [validates interface
compatibility][intro-post] across polymorphic classes sharing the same
role, so they can be used interchangeably. It detects and clearly
reports interface drift directly in your test suite.

Since Duck Typer launched, there's been some discussion about the
validity of interface testing. In this post, I want to make the case
for it.

## "Interface tests are fragile, so you shouldn't write them"

That's not true without context. How is your test suite structured?
What do you test? Obviously, if you write only
interface tests like this:

```rb
def test_interfaces_match
  assert_interfaces_match [StripeProcessor, PaypalProcessor]
end
```

With no behavior tests to accompany it, that quote _will_ be true.
Why? Because you're not testing actual code behavior. Alone, Duck Typer
tests are fragile. So why should you still write them?

## "But I already have behavior tests that catch mismatches"

You do, and they will catch mismatches eventually, assuming you have
good test coverage. The problem is _how_ they catch them. A behavior
test will blow up with a `NoMethodError` or an `ArgumentError`,
but nothing about that tells you it's an interface problem across
a group of classes. You have to figure that out yourself, then
work backwards to find which class drifted and what changed.

Duck Typer short-circuits that investigation. It tells you _what_
drifted and _where_, in a single message, before you ever hit a
behavioral failure:

```
Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: refund(transaction_id)
PaypalProcessor: refund not defined
```

There's also a sharper version of this objection: "You can remove the
implementation and the test still passes, so it's not a good test."
That's true, and it's by design. Duck Typer checks shape, not behavior.
It explicitly marks that a set of classes is expected to evolve
together, and when one changes, the failure makes it clear. That's a
different job than verifying correctness, and both are worth doing.

## It's about quality of life

At thoughtbot, we always valued testing UX and clear error reporting. _We
care about the details_. For example, this is a style of test generally
not encouraged here:

```rb
expect(objects).to eq([post_1, post_2, post_3])
```

Assume that the `post` objects are complex Active Record instances. Can
you imagine what the error message will look like if one object has
differences? It will dump a huge blob of text that incurs overhead to
parse. What are we really testing there? That we're getting the right
objects! Instead, we can use named identifiers to make error reporting
more actionable and crystal clear:

```rb
expect(objects.map(&:title)).to eq(["Post 1", "Post 2", "Post 3"])
```

Duck Typer applies the same principle to interface errors. Without
it, you only get generic Ruby errors that say nothing about
interface drift across classes. With Duck Typer, you also get a
clear, targeted failure:

```
Expected StripeProcessor and BraintreeProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: charge(amount, currency:)
BraintreeProcessor: charge(amount, currency:, description:)

StripeProcessor: refund(transaction_id)
BraintreeProcessor: refund(transaction_id, amount)
```

## It communicates design intent as actionable errors

I wish Ruby had interfaces. As I said in the [introductory
post][intro-post], I want to be alerted of interface drift because it's
a great developer experience feature.

It's not always obvious when classes are supposed to be used
interchangeably. A clear error message communicates which classes
share a role and what shape their interfaces should have.

What if you join a legacy project where the original developers left a
long time ago? Duck Typer would be super helpful there too.

A concrete example: Null Objects. You add a `deactivate` method to
`User`, and your behavior tests for `User` pass. But `NullUser`, which
is supposed to be interchangeable with `User`, silently drifts because
nobody remembered to update it. Behavior tests on `User` won't catch
that. Duck Typer will, immediately, because it treats those classes as a
group that must stay in sync. It also reminds you to write
the actual behavior test for `NullUser#deactivate`.

As a developer who loves targeted feedback, that is right up my alley.

## It helps you think about design

Let's say that introducing a `do_stuff` public method in
`StripeProcessor` is the easiest way to accomplish a goal. You add it,
but get a test failure like the following:

```
Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: do_stuff(data)
PaypalProcessor: do_stuff not defined
```

That message doesn't just report interface drift. It actually asks:

> Why are you doing that? A public method in StripeProcessor should
> also exist in the other processors.

Most likely, your `do_stuff` method is not in the right place. Maybe it
belongs in a collaborator object, or maybe it should be a private method
that isn't part of the public interface at all.

The same applies to differing method parameters; if you introduce a
parameter in one class but it is not needed in another class from the
same interface, you are _probably_ doing something wrong.

## "But that's just like shoulda-matchers"

Not quite. [Shoulda Matchers][shoulda-matchers] are great for shortening
TDD feedback loops when working with Rails conventions. They verify a
single object's declarations: does this model `have_many :posts`? Does
it `validate_presence_of :email`? That's inward-facing: one object, one
declaration.

In fact, once the code has enough behavior coverage, you could delete
the shoulda-matchers tests entirely. They've done their job.

Duck Typer is cross-cutting. It checks whether a _group_ of objects
agrees on a shared interface. The question isn't "does `StripeProcessor`
have a `charge` method?" but "do `StripeProcessor`, `PaypalProcessor`,
and `BraintreeProcessor` all define `charge` with the same signature?"
That's a fundamentally different concern, and one that single-object
matchers can't express.

## "But it doesn't catch errors in production"

Some prefer an approach where the interface is validated at class load
time: declare the contract, and if a class doesn't conform, raise a
`RuntimeError` immediately. That way, mismatches surface as errors in
production rather than only in tests.

That's a valid approach, although not exactly great. In a typed
language, an interface mismatch would never be deployed because the
code wouldn't compile. Ruby doesn't have a compiler, but it has
its own equivalent: the test suite. And guess what inhibits bad
deployments in Ruby projects? In all my years working with Ruby,
I've _never_ seen a project without a CI pipeline. If tests fail,
your code doesn't get deployed. In practice, the safety net is
the same.

On top of that, runtime checks add metaprogramming to your
production code, and you'd still need tests to verify the setup
is correct.

That's why Duck Typer deliberately stays in the test suite: it's
Ruby's natural place to enforce constraints like this, and your
implementation stays clean, without workarounds that try to mimic
static typing at runtime.

If you want compile-time or runtime guarantees, tools like
[Sorbet](https://sorbet.org) or [RBS](https://github.com/ruby/rbs)
take a fundamentally different approach to the same problem and you
wouldn't need Duck Typer. That said, Duck Typer gives you some of those
benefits with a fraction of the effort, at least when it comes to
interfaces.

## Wrapping up

Duck Typer won't replace your behavior tests, and it was never meant to.
It's a small, focused tool that gives you targeted feedback when
interfaces drift. It's usually a one-liner to add, has no runtime
dependencies, and lives only in your test environment. If you value
clear error messages and care about keeping polymorphic classes in
sync, [give it a try][duck-typer].

[intro-post]: https://thoughtbot.com/blog/meet-duck-typer-your-new-duck-typing-friend
[duck-typer]: https://github.com/thoughtbot/duck_typer
[shoulda-matchers]: https://github.com/thoughtbot/shoulda-matchers
