elementary icon indicating copy to clipboard operation
elementary copied to clipboard

[FEATURE REQUEST] Rename @Case to something better

Open toolforger opened this issue 3 years ago • 35 comments

Is your feature request related to a problem? Please describe.

Naming it "Case" is misleading developers into thinking that it is some case distinction (as in "cases of a switch statement").

Describe the solution you'd like

Deprecate @interface Case and add a new annotation with a better name.

Suggestions:

  • @Anchor - borrows from (old) terminology for HTML <a href="#anchor_name">, where the actual anchor is <a name="anchor_name">.
    Both syntax and terminology have been deprecated, today it woud be just the id, as in <span id="anchor_id">.
  • @Element - today's terminology for an anchor, as any HTML element can now be an anchor. The word is pretty content-less though, it's highlighting the least relevant aspect of what we're pointing to: an element inside a body of text (or code, for Elementary).
  • @CodeElement - variation of the above, but making it clearer what kind of element it's supposed to mark.
  • @Marker, @Tag - on-point terminology. Unfortunately, @Tag is also a JUnit 5 annotation.
    • Elementary.Tag, i.e. embed @interface Tag in the Elementary @interface. Now people can just import @Tag, and any who use JUnit's @Tag can still import Elementary and get @Elementary.Tag.
      The record-builder project does this to define @RecordBuilder.Include, @RecordBuilder.Options, and RecordBuilder.Template.
  • @Target or @Focus - meaning it's a point that the test will be looking at.

Just suggestions, if you have a better idea then please use that :-)

toolforger avatar Sep 22 '22 12:09 toolforger

I do think @Case is an appropriate name though since I do think it's similar in concept to that of case distinction. In my mind, we might want to test a single component/lint against various different configurations/cases. In a sense, there are sort of test cases.

For example, we might want to test the behavior of a lint in different cases to ensure that there are no false positive/negatives:

@Case("valid")
void foo(String param) {}

@Case("missing parameter")
void foo() {}

@Case("has return type")
String void(String param)

At least, this is how I envisioned the @Case annotation to be used.

On another note, @Element won't be a good choice since it'll conflict with Java's own Element type.

Pante avatar Sep 23 '22 08:09 Pante

Took me a bit to re-read the article and Case.java, but I have a somewhat clearer picture now indeed, and yes it's clear that @Case was intended to be something like a switch label like in a Java switch statement.

It can be used for other purposes as well.

E.g. I can imagine situations where I want to write a test that accesses some attribute, e.g. to pick up its name to double-check that generated getter/setter/wither names are consistent with the original name despite attached prefixes and suffixes.

Or e.g. the annotation processor will accept overrides and not generate code provided, say, there's a setter with the right name and parameter type:

@Bean
private class BeanTemplate // Not necessarily code that's useful at runtime, just a spec for the annotation processor
  @Case("attribute") private int attr;
  private boolean optional;
  @Case("correct_setter_parameters") public void setAttr(int attr) {
    this.attr = attr;
  }
  @Case("wrong parameter type on setter") public void setAttr(int attr) {
    this.attr = attr;
  }
  @Case("too many parameters on setter") public void setAttr(int attr, boolean optional) {
    this.attr = attr;
    this.optional = optional;
  }
  @Case("no parameter on setter") public void setAttr() {
    this.attr = "NO VALUE";
  }
}

@Case("correct_setter_parameters"), @Case("wrong parameter type on setter"), @Case("too many parameters on setter"), and @Case("no parameter on setter") are indeed case labels, but @Case("attribute") is just a tag so the test can conveniently pick up the attribute name and type from the test in case somebody refactors the example code and renames the attribute.

toolforger avatar Sep 24 '22 19:09 toolforger

If I understand the example correctly, there's two distinct use-cases for @Case,

  • something similar to a case in Java's switch statements
  • labeling additional properties that can be accessed in tests

I think an interesting question is whether we should generalize both use-cases under a new single annotation or split them into two distinct annotations.

If we're going with generalizing the annotation, perhaps @Label, @CodeSnippet or @Snippet?

Pante avatar Sep 25 '22 09:09 Pante

I think yes, that's the use cases. Though I'd say that the case-analog @Cases are just a specialization of the "we label code snippets" idea: A case label is, at least in C, an ordinary label, you can even gotoit... which is horribly unclean, of course, but it's the concept of a label.

I can see reasons for both @Label and @Snippet: @Label since the annotation labels an Element. @Snippet because it's labelling an Element, which is a code snippet. (hmm... @Snippet is a bit less accurate because people generally consider multiple Elements a snippet - e.g. two statements can be a snippet but you can't label them with one annotation, you have to put them into a pair of braces.)

It's still somewhat subjective; I have a slight preference for @Label. The fully spelled-out name would be @ElementLabel since the annotation labels an Element, but that's probably too long and unwieldy.

Name conflict analysis:

  • java.awt.Label. Unlikely to ever be used in modern code.
  • java.jfr.Label. Java Flight Recorder. Not sure if anybody is ever going to write an annotation processor test for that.
  • Various jdk.internal. and sun. packages. That's internal, do we need to consider that?

toolforger avatar Sep 26 '22 12:09 toolforger

Hmmm, seems like @Label is a better candidate then, since looking at it, people may mistakenly assume that @Snippet spans multiple lines/elements.

I don't think most of the name conflicts matter since they belong to quite the niche areas. I daresay @Label will work fine in roughly 90% of all scenarios.

I'll probably rename @Case to @Label on master in that case. It'll probably take awhile to make it to stable though since there's quite significant rework being done to the satisfactory project.

Pante avatar Sep 27 '22 04:09 Pante

Regarding satisfactory - judging by the class names, it seems to be very similar to what Hamcrest does. If that wild guess happens to be correct, it might be a good idea to leave the composability to Hamcrest and merely provide basic predicates like "this is a type". (Just an idea. I know ideas are cheap :-) )

toolforger avatar Sep 27 '22 08:09 toolforger

It's similar that they are matchers but I think that's where the similarity with Hamcrest ends. The idea is to be able to compose matchers for various elements in a declarative manner (as opposed to how most processors imperatively check types).

Those matchers then can automatically produce error messages using Utilitary's message formatting. new-error-message

The message formatting & matching parts are done, I just need to figure out how to create nice error messages based on the matchers. This part's taking quite awhile to sort out.

Pante avatar Sep 29 '22 03:09 Pante

Ah ok then. Given that not everybody will want to learn how to integrate it, or how to write messages that interact nicely, it would be good if elementary did not depend on satisfactory, i.e. they should either be independent, or satisfactory should depend on elementary. (I know what such decoupling creates work, sometimes in considerable amounts, so I'll understand if you don't want to do this.)

toolforger avatar Sep 29 '22 07:09 toolforger

Hmm I'll probably look into splitting it up once I have some spare time. Unfortunately it probably won't happen until at least November.

Pante avatar Sep 30 '22 09:09 Pante

I feel like this mixes 2 features:

  • The ability to get references to specific types of elements that will be used by the annotation processor
  • The ability to easily switch over different elements on a case by case basis

Just renaming the @Case annotation wouldn't fully encompass what you want to achieve. Therefore I propose the following:

@Element annotation with the following annotation values:

  • label this is to define the name with which to retrieve the element in your test (providing this is required)
  • case in case that you want multiple different test cases, one for each test case, similar to a @ParameterizedTest (has a default value)
  • value to make it possible to provide a name like @Element("myAwesomeLabel") instead of having to write @Element(label = "myAwesomeLabel") (so label is basically an alias for value)

Let's give an example of how this would be used. Here is the example from the The Problem With Annotation Processors article:

import com.karuslabs.elementary.junit.Cases;
import com.karuslabs.elementary.junit.Tools;
import com.karuslabs.elementary.junit.ToolsExtension;
import com.karuslabs.elementary.junit.annotations.Inline;
import com.karuslabs.utilitary.type.TypeMirrors;

@ExtendWith(ToolsExtension.class)
@Inline(name = "Samples", source = {
"import com.karuslabs.elementary.junit.annotations.Case;",
"",
"class Samples {",
"  @Case(\"first\") String first;",
"  @Case String second() { return \"\";}",
"}"})
class ToolsExtensionExampleTest {
   
   Lint lint = new Lint(Tools.typeMirrors());
   
   @Test
   void lint_string_variable(Cases cases) {
       var first = cases.one("first");
       assertTrue(lint.lint(first));
   }
   
   @Test
   void lint_method_that_returns_string(Cases cases) {
       var second = cases.get(1);
       assertFalse(lint.lint(second));
   }
   
}

Lets rewrite this with the @Element annotation and add a case:

import com.karuslabs.elementary.junit.Tools;
import com.karuslabs.elementary.junit.ToolsExtension;
import com.karuslabs.elementary.junit.annotations.Inline;
import com.karuslabs.utilitary.type.TypeMirrors;
import javax.lang.model.element.Element;

@ExtendWith(ToolsExtension.class)
@Inline(name = "Samples", source = {
"import com.karuslabs.elementary.junit.annotations.Element;",
"",
"class Samples {",
"  @Element(\"first\") String first;",
"  @Element(label = \"second\", case = \"no param\") String second() { return \"\";}",
"  @Element(label = \"second\", case = \"one param\") String second(String param) { return \"\";}",
"  @Element(label = \"second\", case = \"two params\") String second(String param1, String param2) { return \"\";}",
"}"})
class ToolsExtensionExampleTest {
   
   Lint lint = new Lint(Tools.typeMirrors());
   
   @Test
   void lint_string_variable(Element first) {
       assertTrue(lint.lint(first));
   }
   
   @Test
   void lint_method_that_returns_string(Element second) {
       assertFalse(lint.lint(second));
   }
   
}

This would run 4 tests: lint_string_variable() will run once and lint_method_that_returns_string() will run 3 times (once for each case). For the 3 test cases of lint_method_that_returns_string(), the case string will be used as the name of that test, grouped under the test method name, similar to parameterized tests.

You could still use the Cases test method parameter as well (maybe renamed to Elements or something like that), to be more explicit about what Element you wish to fetch and if you want al test cases to be tested within a single test method call (if that's their personal preference or something). It could also be used to get access to the specific case string from the annotation.

CC007 avatar Oct 11 '22 22:10 CC007

For completeness, here is how the test would look with the Cases/Elements test method parameter:

import com.karuslabs.elementary.junit.Tools;
import com.karuslabs.elementary.junit.ToolsExtension;
import com.karuslabs.elementary.junit.annotations.Inline;
import com.karuslabs.utilitary.type.TypeMirrors;
import javax.lang.model.element.Element;
import java.util.List;
import java.util.Map;

@ExtendWith(ToolsExtension.class)
@Inline(name = "Samples", source = {
"import com.karuslabs.elementary.junit.annotations.Element;",
"",
"class Samples {",
"  @Element(\"first\") String first;",
"  @Element(label = \"second\", case = \"no param\") String second() { return \"\";}",
"  @Element(label = \"second\", case = \"one param\") String second(String param) { return \"\";}",
"  @Element(label = \"second\", case = \"two params\") String second(String param1, String param2) { return \"\";}",
"}"})
class ToolsExtensionExampleTest {
   
   Lint lint = new Lint(Tools.typeMirrors());
   
   @Test
   void lint_string_variable(Elements elements) {
       // maybe it would be better if the one(String label) method was renamed to get(String label) and threw a TestAbortedException (the same one that junit5 assumptions throw) rather than returning null if there isn't exactly one element
       Element first = elements.get("first"); 
       assertTrue(lint.lint(first));
   }
   
   @Test
   void lint_method_that_returns_string(Elements elements) {
       // without case string
       List<Element> secondList = elements.asList("second");
       secondList.foreach(second -> {
           assertFalse(lint.lint(second));
       }
       
       // with case string
       Map<String, Element> secondMap = elements.asMap("second");
       secondList.foreach((case, second) -> {
           assertFalse(lint.lint(second), "For case: " + case");
       }
   }
   
}

CC007 avatar Oct 11 '22 23:10 CC007

I also think that Cases.get(int index) can be removed if needed, as it seems very error-prone to retrieve elements without label, especially if you retrieve them from a possibly nonexistent index.

If you do still want the functionality, you can always use Elements.asList("myLabel").get(1);

CC007 avatar Oct 11 '22 23:10 CC007

I feel like both cases can be generalised as “get elements annotated with a label”.

I’m not too particularly fond of cases for parameterised tests as it isn’t obvious from a glance that a test is parameterised since it’s not annotated with @ParameterizedTest. People not familiar with elementary might be caught off-guard by this. I’m also not too sure if it’s possible to re-execute a test marked with @Test but with different parameters each time.

I feel a simpler way will just be to mark a test with @ParameterizedTest and a list of labels instead. If that doesn’t work at the moment, then I think effort should be in this direction instead. Furthermore, it’s less surprising.

I don’t think renaming Case And Cases to Element and Elements is a good idea since they will conflict with Java’s own TypeMirror API. @Label and Labels might be better.

Re-thinking about it, I agree that Cases.get(index) should be removed since it’s extremely confusing & error-prone.

Huh, TIL that TestAbortedException existed, I agree that the method should definitely throw the exception rather than returning null.

Hmm regarding renaming one() to get(), perhaps it will be better to enforce that all labels are unique & maybe provide some forming of matching on that instead.

Pante avatar Oct 12 '22 04:10 Pante

I don't think that the proposed Element annotation would ever conflict with that API, except when someone tests an annotation processor that processes annotations that are used in an annotation processor (like google testing their @AutoService annotation processor or something), but if you indeed remove the case from the annotation, you can rename it to @Label.

I do think that it would be beneficial if there still was a @CaseLabel annotation or something and that you could use a @ParameterizedTest with a @CaseLabelSource to which you provide the label that was used in the @CaseLabel annotation.

CC007 avatar Oct 12 '22 11:10 CC007

On 12.10.22 00:48, CC007 wrote:

I feel like this mixes 2 features:

  • The ability to get references to specific types of elements that will be used by the annotation processor
  • The ability to easily switch over different elements on a case by case basis

Sure it's two different use cases. Question is whether they need different kinds of support; if both use cases just need to name some element, one could go for the "toolbox" approach: giving the programmer tools rather than precanned workflows.

@.***| annotation with the following annotation values:

  • |label| this is to define the name with which to retrieve the element in your test (providing this is required)
  • |case| in case that you want multiple different test cases, one for each test case, similar to a @.***| (has a default value)
  • |value| to make it possible to provide a name like @.("myAwesomeLabel")| instead of having to write @.(label = "myAwesomeLabel")| (so |label| is basically an alias for |value|)

Would one still need "case" to make parameterized test cases? Is "case" needed for nonparameterized test cases, does it even make sense?

There's also the downside that each additional detail means that the user has to remember it to successfully use it. (In alignment with "perfection is not when there is nothing to add, but nothing to take away", though that just a perspective to keep in mind, not something to tick off.)

This would run 4 tests: |lint_string_variable()| will run once and |lint_method_that_returns_string()| will run 3 times (once for each case). For the 3 test cases of |lint_method_that_returns_string()|, the |case| string will be used as the name of that test, grouped under the test method name, similar to parameterized tests.

Does @.| automatically trigger tests? In that case, yes the annotations are different. I'm wondering whether @.| is overlapping with @.| though. Because then the developer has to keep in mind that @.| not just marks an element, it also triggers test repetition.

Downsides:

  • @Case overlaps with both @Label and @RepeatedTest.
  • More details to remember.
  • You can't make a RepeatedTest that accesses two elements (e.g. for testing annotations that check consistency between otherwise unrelated elements). Upsides:
  • No need to write the case label twice.

Of course, you can't just count upsides and downsides, you need to weigh them, taking value and likelyhood into account.

I do agree that accessing elements by index is very error-prone. It might be a good idea to allow a name and an index, so you can select an element according to "the Nth element labelled 'foo'" or similar.

toolforger avatar Oct 12 '22 18:10 toolforger

Would one still need "case" to make parameterized test cases? Is "case" needed for nonparameterized test cases, does it even make sense?

case doesn't make sense for non-parameterized test cases, so that would mean that you probably would want a difference between @Label and @CaseLabel, where the latter is only used in parameterized tests. It would be possible to make the case argument optional, meaning that the cases would just have an index. You would just have to think about what to do when case labels with and without case argument are mixed. Do you want to disallow that or not, and if not, how would you handle that.

I think that focussing on the @Label annotation at first would be more important though and if we want elementary to support parameterized tests later, it can always be added then.

Thinking about it though, we might be missing the point here. What are we trying to achieve? All we want is to have access to a certain Element object that has a certain structure. The way we get it doesn't really matter, so long as it is possible to retrieve with the given workaround. Sometimes it would be enough to just have the element for a field declaration or a method declaration, so having to specify the full class around it would be boilerplate that can be added automatically.

import com.karuslabs.elementary.junit.TestElements;
import com.karuslabs.elementary.junit.TestField;
import com.karuslabs.elementary.junit.TestMethod;
import com.karuslabs.elementary.junit.TestClass;
import com.karuslabs.elementary.junit.Tools;
import com.karuslabs.elementary.junit.ToolsExtension;
import com.karuslabs.utilitary.type.TypeMirrors;
import javax.lang.model.element.Element;

@ExtendWith(ToolsExtension.class)
class ToolsExtensionExampleTest {
   
   Lint lint = new Lint(Tools.typeMirrors());
   
   @Test
   @TestFieldElement(label = "thatOneProblematicField", element = "private final @Foo String myPrivateAnnotatedStringField;", imports = {"my.package.Foo"}),
   void lint_string_variable(TestElements elements) {
       Element field = elements.get("thatOneProblematicField");
       assertTrue(lint.lint(field));
   }
   
   @Test
   @TestMethodElement(label = "mainMethod", element = "public static void main(String[] args){return null;}")
   @TestClassElement(label = "myGenericClass",
                     element = "public class Foo<T extends Bar> implements Baz<T> {}", 
                     imports = {"my.package.Foo", "my.package.Bar", "my.otherpackage.Baz"})
   void lint_main_method_and_class(TestElements elements) {
       Element method = elements.get("mainMethod");
       Element clazz = elements.get("myGenericClass");
       assertTrue(lint.lint(field));
       doSomethingWith(clazz);
   }
   
}

Given the specific annotation, we know exactly how to generate the boilerplate around the element (if the imports are defined correctly), so we don't need the tester to deal with that.

CC007 avatar Oct 13 '22 00:10 CC007

I disagree since doing static code analysis in an annotation processor will almost always rely on the Element/TypeMirror API, which IMO is quite a common use-case.

A @CaseLabelSource does sound promising, perhaps we can go with the original idea of having a case/group field on the @Label annotation.

Pante avatar Oct 13 '22 02:10 Pante

I disagree since doing static code analysis in an annotation processor will almost always rely on the Element/TypeMirror API, which IMO is quite a common use-case.

I do agree that the Element/TypeMirror api is a common use case. What I'm trying to achieve here is actually to still have the class definition be generated behind the scenes, so that the Element objects are available for your test. This will prevent the user from having to write the boilerplate around the element of interest and skip the parts that aren't directly used in the test (eventhough they will still be available when compiling and creating the Element objects).

CC007 avatar Oct 15 '22 17:10 CC007

I think there's a misunderstanding, I just meant that I specifically disagreed with renaming the annotation to @Element. I'm still on the fence regarding the others.

Pante avatar Oct 16 '22 03:10 Pante

Ah ok. The annotation processor does indeed heavily rely on Element, but the code that actually would be given the @Element annotation wouldn't usually rely on Element, right? As this would be the compilation target.

CC007 avatar Oct 16 '22 09:10 CC007

I think the main issue is that during the test we want access to both Java's Element and the @Element annotation.

Sometimes it would be enough to just have the element for a field declaration or a method declaration, so having to specify the full class around it would be boilerplate that can be added automatically.

Re-reading this it seems like I missed out this part, I think we already have a far superior mechanism for eliminating boilerplate code, the @Introspect annotation, it allows us to declare @Cases in the test class itself.

@ExtendWith(ToolsExtension.class)
@Introspect
class ToolsExtensionExampleTest {

    Lint lint = new Lint(Tools.typeMirrors());
    
    @Test
    void lint_string_variable(Cases cases) {
        var first = cases.one("first");
        assertTrue(lint.lint(first));
    }

    @Case("first") String foo() { return "";}
    
}

class Lint {
    
    final TypeMirrors types;
    final TypeMirror expectedType;
    
    Lint(TypeMirrors types) {
        this.types = types;
        this.expectedType = types.type(String.class);
    }
    
    public boolean lint(Element element) {
        if (!(element instanceof VariableElement)) {
            return false;
        }
        
        var variable = (VariableElement) element;
        return types.isSameType(expectedType, variable.asType());
    }
    
}

This feature does not benefit from renaming @Case to @Element.

Perhaps another issue at hand here is the discoverability of @Introspect?

Pante avatar Oct 17 '22 04:10 Pante

I created a rough prototype here. Do let me know your thoughts!

@Case was renamed to @Label. One important difference is that all labels must now have a unique value. This differs from @Case where a value was neither required nor unique. Additionally, a new group attribute was introduced to @Label. This allows us to retrieve all @Labels in a group. Groups are expected to tie-in nicely with support for parameterized testing further down the line.

Cases have been reworked into Labels. Accessing values by index has always been error-prone and hence no longer supported. A few nice QOL changes have been made to it, including the addition of single(). I'm not too sure what other ergonomic changes are missing. Feedback on it is greatly appreciated. In general, I tried to avoid anything related to sequencing/ordering since the order of elements is highly likely to be subject to frequent changes.

Pante avatar Oct 17 '22 06:10 Pante

Perhaps another issue at hand here is the discoverability of @Introspect?

Ah while I had seen @Introspect, I hadn't looked into what it does. Most of my knowledge comes from this article.

Looking at the wiki, I see that it is described there. The only thing I'd add there is a code example, but other than that it's fine.

But taking into account @Introspect, I agree that @Element isn't a good name due to the naming conflict and that @Label is the better choice here.

I like the label changes

CC007 avatar Oct 17 '22 10:10 CC007

For the single() implementation you can also use elements.values().get(0). That way you can remove the unnecessary loop and throw new IllegalStateException("This exception should never be thrown");

CC007 avatar Oct 17 '22 10:10 CC007

Also, you could use the TestAbortedException (which is also used by Junit Jupiter uses for test assumptions), instead of the IllegalStateExceptions

CC007 avatar Oct 17 '22 10:10 CC007

For the single() implementation you can also use elements.values().get(0). That way you can remove the unnecessary loop and throw new IllegalStateException("This exception should never be thrown");

element.values() returns a Collection which doesn't have a get(index) method. Although now that you mention it, I could convert it into an array and retrieve the single element.

Also, you could use the TestAbortedException (which is also used by Junit Jupiter uses for test assumptions), instead of the IllegalStateExceptions

I did think about it, but I figured an IllegalStateException works just as well without having to include an additional dependency.

Pante avatar Oct 17 '22 11:10 Pante

Fair. While I believe that the dependency is included with Junit Jupiter, it is always good to not rely on transitive dependencies and to use as few dependencies as possible when writing a library, to prevent dependency conflicts, so I agree with that choice.

CC007 avatar Oct 17 '22 11:10 CC007

Hearing no other objections, I'll be going ahead with the proposed changes and finalizing them.

Pante avatar Oct 22 '22 15:10 Pante

If possible(!), keep the old annotations and Javadoc them as @deprecated, explaining how to migrate to the new system.

toolforger avatar Oct 22 '22 19:10 toolforger

We're planning to include this in 2.0.0, which probably will be a clean break.

Pante avatar Oct 23 '22 01:10 Pante