Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Ide test integration #46

Closed
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions proposals/accepted/50-ide-test-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
## Abstract

This proposal seeks technical coordination from the Haskell Fondation
for improving integration between IDEs and testing libraries in Haskell.

This work will involve two major implementation areas: testing library
integration, and IDE integration. The author will aim to implement both
of these features, though help is certainly welcome :).

This undertaking requires buy-in and feedback from the maintainers of
Haskell's testing frameworks, since we would like to have one interface
that satisfies everyone's needs.

## Background

IDE integration for test suites is a feature that is expected in most
if not all major programming languages. First class support exists for
[JUnit with multiple IDEs](https://junit.org/junit5/docs/current/user-guide/#running-tests-ide),
[Python with VSC](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwiRyt_VwcD7AhUXIUQIHQe1CjUQFnoECBIQAQ&url=https%3A%2F%2Fcode.visualstudio.com%2Fdocs%2Fpython%2Ftesting&usg=AOvVaw0U1x8oEhgUTX1GeoPJqVqo),
[.NET with VSC](https://marketplace.visualstudio.com/items?itemName=formulahendry.dotnet-test-explorer)
and many others.

Often these IDEs will supply inline hints for "Run this test" and
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I think a screenshot would be very useful for Haskellers who don't use IntelliJ much. This one came up from a quick image search, for instance: https://i.stack.imgur.com/j5f1o.png

"Re-run failed tests" as well as others. For users of mainstream
languages, such features are no longer features, but instead
expectations from a mature language.

Haskell has a mature and well-maintained testing ecosystem, including
the libraries: `tasty`, `HUnit`, and `hspec`. While these libraries
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about QuickCheck and friends? Is the intention that they are subsumed by the mention of Tasty here?

themselves are mature, there is currently minimal
integration between Haskell's testing libraries and HLS, as well as
the primary IDEs used for Haskell (VSC;Emacs;Neovim).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
the primary IDEs used for Haskell (VSC;Emacs;Neovim).
the primary IDEs used for Haskell (VSC, Emacs, and Neovim).


Attempts at integration between testing frameworks have focused on
regex searches, with support for hspec:
Comment on lines +34 to +35

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neotest uses https://tree-sitter.github.io/tree-sitter/ with https://github.com/tree-sitter/tree-sitter-haskell instead of regex. It still has its shortcomings to detect test cases, but at least in terms of syntax recognition it's much more robust as it uses a real parser.

1. [neo-test](https://github.com/mrcjkb/neotest-haskell)
2. [vim-test](https://github.com/vim-test/vim-test)

While these are valuable contributions to the ecosystem, their choice
of regex for the collection of the user's test tree, and support for only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term "test tree" is not necessarily understood by the audience for this proposal . Based on our discussion elsewhere, I think you made a good observation about how Haskell test libraries are typically (but not always) organized, and how it's different from something like JUnit-derived tests. In particular, JUnit-inspired libraries typically use the language's notion of a declaration or definition to bound tests, so a test is a class or a method, while Haskell libraries more frequently use an ordinary data structure to represent tests. This means that we'd need to be a bit creative about the UI, and draw inspiration from things like IntelliJ while still doing something a bit different that fits Haskell better.

I think the proposal would be accessible to more readers if these assumptions and observations were made more explicit in this background section.

specific frameworks are symptomatic of a lack of ecosystem coordination.

## Motivation

With the implementation of this proposal, the Haskell community will
have gained a valuable IDE tool which will improve the user experience
of Haskellers as well as make Haskellers more productive.

Support for IDE integration of testing libraries will provide the following
benefits:
1. Ability for IDEs with Haskell support to provide inline hints for "Run
Tests" style commands.
Comment on lines +51 to +52

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In visual studio code and other editors supporting the language server protocol, this is often accomplished with code-lens, in combination with custom client-side commands.
This is something that afaik the go and rust language-servers use and support.

Another example where they use the language-server but without code-lens would be https://github.com/microsoft/vscode-java-test
It uses the executeCommand message with a custom command implementation that returns test locations (https://github.com/microsoft/vscode-java-test/blob/main/java-extension/com.microsoft.java.test.plugin/src/main/java/com/microsoft/java/test/plugin/handler/TestDelegateCommandHandler.java)

2. Ability for IDEs with Haskell support and support for a test explorer
view to display the user's test tree and run individual tests.
3. A unified framework for testing libraries to provide integration with

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to offer a unified CLI interface also, perhaps?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a really nice result!

IDEs. When a new testing framework is developed, they will be able to
provide the results in a Haskell datatype via serialization, which
avoids framework-specific test tree discovery logic.
4. Potential to share test result rendering logic between different testing

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up until this point I thought this was mostly about test discovery, but now it sounds like you also want to standardise test reporting. That might be harder!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that running individual tests from the editor requires both. Discovery allows something like HLS to say "this is a test, want to run it?", and reporting allows HLS to say "It ran, and it passed".

frameworks, whereas currently each testing framework has their own test result
display logic.

## Goals

1. Create a new library, `hs-test`, with common datatypes for testing framework
test trees, and test results.
2. Generate test trees and test results that can be serialized into JSON from

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have in mind concepts of "test tree" and "test results", it would really help if you made that explicit. The technical section below does that, but I think it would be helpful to have something in the initial prose, and maybe an argument that it's adequate for all usecases!

Haskell's major testing frameworks, starting with `tasty`.
Comment on lines +67 to +68

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There would also be the Build Server Protocol which is adressing these requirements, but so far it didn't see much adaption so I guess JSON is be the safer/easier route. But maybe it's still useful to have a look at it and see how they modelled their solution.

3. Integrate test tree output output with VSC's Haskell extension, via VSC's [Testing API](https://code.visualstudio.com/api/extension-guides/testing).
4. Stretch goal: support IDEs other than VSC.
5. Stretch goal: support test results caching, and features such as "Rerun only failed tests".
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't tell if this goal is a subgoal of number 3, or if it's something that should be supported by the other frameworks. Can you clarify it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is, is it a part of the JSON output format, or is it a VS Code extension feature?

Comment on lines +49 to +71

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I am aware, this kind of functionality is often implemented on the client side/test-extension itself and doesn't need extra per language implementation.


## Technical Proposal

The `hs-test` library will contain the following data declarations:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that being concrete is great here, but you should add a bit of wiggle room in case you learn something in the process. What about:

Suggested change
The `hs-test` library will contain the following data declarations:
The `hs-test` library will contain data declarations along the lines of:


```haskell
-- | Datatype representing all the tests that exist in a test
-- suite that could be run.
--
-- `SrcLocs` are used to indicate where inline hints should be displayed
-- in an IDE.
data TestTree =
TestGroup {name :: Text, subTrees :: [TestTree], location :: SrcLoc}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From where should the testing library get the SrcLoc? It's not easy for Haskell programs to view their own source code like this, and I'd be worried to promote an ecosystem-wide convention that requires fragile kludges to be used successfully.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we definitely want the location to be optional.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we're almost certainly going to want this to be extensible in some way. I think it's very likely that different testing frameworks will want to annotate the tree with framework-specific metadata of their own, and we should make supporting that a key design goal.

| TestCase {name :: Text, location :: SrcLoc}

data RunTestsRequest =
RunTestsRequest {path :: NonEmpty Text}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in Tasty guarantees that the names are unique, and if I have my Tasty test tree factored out into a variety of top-level definitions, then I could very easily end up accidentally reusing a name. I would then be surprised if the wrong test is run when I click on it, and it might make me miss a bug.

Is there another way to represent the path?

For instance, what about a redundant encoding of name-position pairs?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working out what I imagine would happen here:

  • The path gets turned into a tasty pattern
  • The pattern matches multiple tests
  • Multiple tests get run

Seems somewhat bad but maybe acceptable?


-- | A lazy list in order to support arbitrary execution
-- order and streaming between the test runner and IDE
data TestResults = TestResults [TestCaseResult]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a lazy list rather than something in IO to support a hypothetical pure test runner? Does such a thing exist?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the JUnit report format is already widely supported. For instance, Gitlab CI will provide detailed test suite summaries if the tests output this format. Why invent a new one here, rather than using the existing standard? (I'm not saying we shouldn't invent a new thing, but the proposal should say why novelty is necessary).


data TestCaseResult = TestCaseResult {
-- | Represents the path to get to this test case from the
-- TestTree, where each element represents a node in the original
-- TestTree.
path :: NonEmpty Text,
result :: TestResult,
timeTaken :: Maybe Double
}

data TestResult = Success | Skipped | Failure FailureReason
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having timing information is great. Is it enough?

Using Haskell datatypes as a means of thought is great, but I'd almost prefer the proposal to just define the JSON format. This would allow subtypes to be built (read: with extra fields) to allow the community to experiment with what additional information is useful on top of a common core. For instance, QuickCheck tests might want to show how many tests passed vs were skipped, and that would be useful feedback in my editor.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is why I think this really needs to be extensible!


newtype FailureReason = FailureReason Text
```

It will also contain the logic to encode this datatype
to JSON, for consumption by IDEs.

------------

For testing frameworks that want to support IDE integration,
they should provide support for the following commands:

```bash
./test-suite test-tree --output="FILE_NAME"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is clearly bikeshedding. Sorry!

But I would expect that a protocol designed for machines would not take up an entry in the human-friendly subcommand namespace, and would instead do everything through--long-options. And I would really want the output to be able to come over stdout rather than into a file if I were building a front-end for this, because then I don't have to worry about cleaning up temp files and the like.

./test-suite run-tests --input="FILE" --output="FILE"

# Sample usages:
> cabal test test-suite-name \
\--test-options='test-tree --output="output.tmp.json"'
> cabal test test-suite-name \
\--test-options='run-tests --input="input.tmp.json" --output="output.tmp.json"'
```

The `test-tree` command outputs a JSON-serialized `TestTree` to the given
`output` file.

The `run-tests` command reads a JSON-serialized `RunTestsRequest` from
`input`, and, after executing tests, writes the result to the `output` file.

`hs-test` will provide helper functions for these serialization tasks.

-------------

For IDEs, they will utilize these commands to:
- find the test tree
- execute a test run request, and display the results to the user.

One problem that could occur is that having the IDE run `cabal test` to

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having to actually run a cabal command to find this out is a weakness. Junit and friends can know most of this information statically, just by compiling the code, which is quite nice! That does mean that they have to rely on stuff like the hierarchy being implicit in static information (like the class hierarchy), but it is otherwise quite nice. I think we should at least think about whether we can do this more statically (or at least without cabal in the loop!!).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g. HLS has some plugins that load code into the interpreter and run it, so we could plausibly compute the test tree without going through cabal, if we do need to run something.

reindex the `TestTree` is too slow and incurs too large a latency for
the user. However, this problem is no worse than the current situation,
in which the user has to call `cabal test` anyway in order to run the tests.

Furthermore, IDEs often having access to stale artifacts for code that used
to compile but no longer compiles, therefore, the IDE could run the `test-tree`
command while still displaying a mostly-correct test tree and inline hints
in the user's IDE. Once the call to `test-tree` has completed, the new test
tree can be displayed to the user.

## Alternative Approaches

### A typeclass driven approach with HLS integration

Described in the following [discourse comment](https://discourse.haskell.org/t/hf-coordination-for-test-framework-ide-integration/5221/2?u=santiweight)

In summary, take each function signature and detect whether said function
is runnable as a test based on whether its type is a member of the
proposed typeclass `IsTest`. Testing frameworks will each provide their
own `IsTest` instances for their common testing types.

I have chosen the option in this proposal for the following reasons:
1. Such an approach would run Haskell code in GHCi, which is slower, and
would therefore scale poorly for production codebases.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is actually true - the type class based approach seems to me like it could support compiled or interpreted mode, and that GHCi is an implementation strategy not forced by the design. Is there something I'm missing?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't understand this. For running the tests you could still run them through cabal or whatever, assuming that you have some way of knowing how to do that.

2. Such an approach requires integration with GHC's API, which the author
is unfamiliar with and therefore cannot be confident in their ability to
maintain the integration library.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the design would require the IDE author to know the GHC API - hopefully this would be done once in HLS and then everyone else would get it for free. But the author of e.g. QuickCheck or Tasty would just need to write a four-line instance declaration.

3. The author cannot see how to acquire the test tree that is common to
IDE test integration, since it requires inspecting the typechecking artifacts
of all files in the test suite.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a true limitation with respect to your proposal. Haskell test libraries really do organize things on a basis other than top-level declarations, and it would be good to not reinvent the wheel. I like that your proposal is highly compatible with what everyone's already using.

4. Sometimes testing frameworks will provide conflicting `IsTest` instances for the
the same type, which is a problem when using multiple testing frameworks in the
same codebase.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the permanent problem of type classes. Such conflicting instances would always be orphans, right? I think this is no worse than any other Haskell type class.


The typeclass proposal seems incredibly useful however for the typical `Run Program`
command now provided, or as an additional tool for users. It is certainly a good
idea for a future project.

### Regex-based test discovery

While regex discovery is a good and quick solution implementation wise, it does
not scale to many real-world usecases. For example, many codebases will have helpers
such as:

```haskell
testDecodeJsonFromFile fileName = testCase fileName $ readJsonFromFile fileName
```

Since the fileName value is not known until runtime, regex-based approaches miss
such cases, which is a non-starter for an ecosystem-wide tool.

The proposal put forth by the author will also have to handle this use-case, by
providing support in testing frameworks for users to define their own combinators
that declare their location on use site. But this problem is a purely technical
one with many solutions, such as the following pseudo-code:

```haskell
testDecodeJsonFromFile :: HasCallStack => TestTree
testDecodeJsonFromFile = Tasty.customTestCase callstack fileName $ readJsonFromFile fileName
```

## Future Work

This proposal is purposefully minimal in scope, for a number
of reasons:
1. In order for testing frameworks to buy in, we will need a
minimally-controversial interface.
2. Support for more features should be done incrementally, since
the design space here is rather large, and we will not know users'
needs until after integration is alright out in the real world.

Here are some features that have been neglected in this proposal:
1. Distinction between "error" and "failure" when a test fails.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this not a mere matter of adding an additional constructor to TestResult?

2. Support for multiple filters when running a test.
3. Support for options when running a test, such as: `--accept` when
running golden tests, `-jN` to run tests in parallel, and options
related to property-based tests.
4. Support for localized options when running a test.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are localized options? In my head it would be something like replacing --accept with --godtage to make the Danish localization, but this doesn't seem like a very plausible interpretation of what you're writing.