Testing non-public instance and class methods

Options:
a. Obliterate protected and private during testing with a concise macro (is this possible?)

# visibility_macros.cr
macro _private do
  {% if !flag?(:test) && !env("TEST") %}
  private
  {% end %} 
end

macro _protected do
  {% if !flag?(:test) && !env("TEST") %}
  protected
  {% end %} 
end

# foo.cr
require "./visibility_macros"

class Foo
  _private def self.foo # or a variation thereof
  end

  _protected def bar
  end
end

b. Break up methods into include and extend “concern” modules. Downside is more layers of abstraction.
c. (Your idea here)

If it matters, I’m frequently using spectator

Another option could be to make use of Macro Hooks to do some dirty tricks. By dirty i mean what i’m doing with arg expansion. I’m sure there are much better and safe approaches on dealing with that, so take this below code as an experimental or inspration only :slight_smile:

module TestHelper
    macro included
     {% if flag?(:test) && env("TEST") %}
     {% verbatim do %}
        macro method_added(method)
            {% if method.visibility == :private %}
                def {{method.name.id}}({{method.args.stringify[1...-1].id}})
                    {{method.body}}
                end
            {% end %}
        end
     {% end %}    
     {% end %}
    end
end    

class Foo
    include TestHelper

    def foo(a : Int, b : String)
        p! a, b
    end

    private def bar(s : String, v = true)
        p! s,v
    end
end

f = Foo.new
f.bar("voila") # this would compile only when conditions specified in TestHelper are met

IMO it depends on the context of what your project is doing/is intended for. Regardless tho, having to have special logic within the non-test code itself is a bit of a code smell.

I personally think testing private/protected methods is a bit of a bad practice most of the time. The main reason being these methods are usually used as implementation details within a type while the public methods are the ones you actually interact with, which should be the main focus of your testing.

By only testing the public methods, you still are asserting the type functions as it should, since the private/protected methods will be used internally, but without tightly coupling your tests to the internals of the type. E.g. in an ideal world you could go thru and refactor the internal implementation and use your tests of the public API to validate your changes.

However, context also plays a role in this topic. If your code is for private use only, and won’t be used by others outside of you/your team or whatever, then you have a bit more freedom in that you could just make all your methods public and use doc comments or something to denote they’re internal. In a similar vein, the :nodoc: doc comment can be useful for this, even when your code may be used by others, in that it can allow a method to be public, but not part of the public API.

In the end it’s your decision, and there is no “right” answer, but might be worth taking a step back and ask yourself if it’s actually worth trying to hack something in to do this, or just take a different approach/refactor the code to avoid the issue entirely.

2 Likes

I second that. If a method is private, it’s typically not a good target for tests. If it was, it would perhaps rather be public.

Anyway, I’d most certainly recommend about any special casing for test mode with compile time flags and such. That’s just adding complexity and can lead to weird behaviour when your code is different in a test scenario.

If you must test a private method, the best course of action IMO is to reopen the type in the test code and add a method of the same name but with _public suffix which calls the private method.

3 Likes

Trust me, if someone write tests often enough, a lot of times, there will be test private method(it probably the most easy/simple way to ensure logic correct), but, still have reason to keep this method private.

This. I’ve used subclassing the class under test in languages that doesn’t allow reopening in order to get at protected methods, but in that world private might be a major problem in testing. Being able to reopen is one of the things I love about Crystal.

1 Like