greghaskins / spectrum

A BDD-style test runner for Java 8. Inspired by Jasmine, RSpec, and Cucumber.
MIT License
145 stars 23 forks source link

Feature Request: Support for Shared Examples and Shared Contexts #130

Open GuyPaddock opened 6 years ago

GuyPaddock commented 6 years ago

This may just be something I'm overlooking in the docs -- or that could use an example -- but is there a way to do RSpec-style shared contexts and shared examples?

For example, I just wrote my first test using Spectrum, which looked like this:

@RunWith(Spectrum.class)
public class EnumsTest {
  {
    describe(".findValueOrThrow", () -> {
      context("when given an Enum that has no values", () -> {
        final Supplier<Class<EmptyEnum>> enumClass = let(() -> EmptyEnum.class);

        it("throws an IllegalArgumentException", () -> {
          assertThatExceptionOfType(IllegalArgumentException.class)
            .isThrownBy(() -> {
              Enums.findValueOrThrow(enumClass.get(), (value) -> true);
            })
            .withMessage(
              "No `com.rosieapp.util.EnumsTest.EmptyEnum` was found that matched the specified " +
              "filter.")
            .withNoCause();
        });
      });

      context("when given an Enum that has values", () -> {
        final Supplier<Class<Colors>> enumClass = let(() -> Colors.class);

        context("when the predicate does not match any of the values", () -> {
          final Supplier<Predicate<Colors>> predicate = let(() -> (color) -> false);

          it("throws an IllegalArgumentException", () -> {
            assertThatExceptionOfType(IllegalArgumentException.class)
              .isThrownBy(() -> {
                Enums.findValueOrThrow(enumClass.get(), predicate.get());
              })
              .withMessage(
                "No `com.rosieapp.util.EnumsTest.Colors` was found that matched the specified " +
                "filter.")
              .withNoCause();
          });
        });

        context("when the predicate matches one of the values", () -> {
          final Supplier<Predicate<Colors>> predicate = let(() -> (color) -> color.name().equals("WHITE"));

          it("returns the matching value", () -> {
            assertThat(Enums.findValueOrThrow(enumClass.get(), predicate.get()), is(Colors.WHITE));
          });
        });

        context("when the predicate matches multiple values", () -> {
          final Supplier<Predicate<Colors>> predicate = let(() ->
            (color) -> !Arrays.asList("RED", "WHITE").contains(color.name()));

          it("returns the first matching value, according to the order within the enum", () -> {
            assertThat(Enums.findValueOrThrow(enumClass.get(), predicate.get()), is(Colors.BLUE));
          });
        });
      });
    });
  }
  // ...
}

Two of those scenarios are expected to throw the same exception with nearly the same error message. In RSpec, I'd abstract that out into a shared example group and then control what's provided using let. If shared example groups are out of the question, what would be the best practice when using Spectrum for this?

ashleyfrieze commented 6 years ago

We have support for something similar via the Gherkin syntax - https://github.com/greghaskins/spectrum/blob/master/docs/GherkinDSL.md - but could not yet decide how to make it look for the RSpec style syntax.

Can you give an example of how you do it in RSpec, especially relating to the overloading of let.

ashleyfrieze commented 6 years ago

Note - this is essentially the same as #80

GuyPaddock commented 6 years ago

In our case, the request is a bit different than #80. The way we usually handle this in RSpec is by defining a shared example group:

class Cat
  def make_sound
    "Meow!"
  end
end

class Dog
  def make_sound
    "Woof!"
  end
end

shared_examples_for 'an animal' do
  it 'makes a sound' do
    expect(animal.make_sound).not_to be_nil
  end
end

describe Cat do
  let(:animal) { Cat.new }

  it_behaves_like 'an animal'
end

describe Dog do
  let(:animal) { Dog.new }

  it_behaves_like 'an animal'
end
GuyPaddock commented 6 years ago

I was able to get a workable but verbose solution using a functional interface and lambda declared in the test. The lambdas are invoked from within the context of each overall test.

Here's a watered-down example (imagine that each lambda could actually have 5-10 tests, and the only things being passed-in are the suppliers for the values that vary from test to test):

@RunWith(Spectrum.class)
public class MyTest {
  {
    final Supplier<Object> someObject = let(() -> new Object());

    final Supplier<MyObject> testObject = let(() -> new MyObject(object.get()));

    final ComparisonSharedExample behavesLikeRegularEquality = (testMethod) -> {
      it("returns the match", () -> {
        assertThat(testMethod.get()).isSameAs(someObject.get());
      });
    };

    final ComparisonSharedExample behavesLikeOppositeEquality = (testMethod) -> {
      it("does not return the match", () -> {
        assertThat(testMethod.get()).isNotSameAs(someObject.get());
      });
    };

    describe("#compare1", () -> {
      behavesLikeRegularEquality.run(
        () -> {
          return testObject.get().compare1();
        });
    });

    describe("#compare2", () -> {
      behavesLikeOppositeEquality.run(
        () -> {
          return testObject.get().compare2();
        });
    });

    describe("#compare3", () -> {
      behavesLikeRegularEquality.run(
        () -> {
          return testObject.get().compare3();
        });
    });

    describe("#compare4", () -> {
      behavesLikeOppositeEquality.run(
        () -> {
          return testObject.get().compare4();
        });
    });
  }

  interface ComparisonSharedExample {
    void run(final Runnable testMethod);
  }
}