cucumber-js icon indicating copy to clipboard operation
cucumber-js copied to clipboard

[Proposal] Enable use of Enums in Cucumber Expressions / Steps

Open nextlevelbeard opened this issue 3 years ago • 8 comments

Run-able Proposal Playground

In its essence, allow for something like this to happen:

// Expose registerTypes to user
import { Then, defineParameterType, registerTypes } from '@cucumber/cucumber';

enum Fruit { 'Apple', Orange, Cucumber }
enum Vegetables { Potato, Eggplant }

// Calls defineParameterType for every Enum thrown in single object parameter
registerTypes({ Fruit, Vegetables })

Then('I should have an? {Fruit} in my basket', async function (fruit: keyof typeof Fruit) {

  // 🎉 fruit parameter is now type-safe! 
  // TypeScript will let you know: This first condition will always return 'false'
  // since the types '"Apple" | "Orange" | "Cucumber"' and '"Worm"' have no overlap.ts(2367)
  
  if(fruit === 'Worm'){
    console.log("Asserting its a Worm ?!")
  }
  
  // We can reference the original enum in comparisons
  if(fruit === Fruit[Fruit.Orange]){
    console.log("Asserting its an orange!")
  }
  
  // Or
  if(fruit === 'Orange']){
    console.log("Asserting its an orange!")
  }
}

To take note:

  • Users don't need to write down their N possible options on neither:

    1. The Cucumber Expression

      • Was already possible with defineParameterType but this registerTypes syntax makes things less verbose There's now no concept of transformer, name and regex, just an enum
    2. The parameter type (new!)

      • User extrapolates type by doing keyof typeof MyEnum
  • Users can reference the original enum for comparisons inside the step: fruit === Fruit[Fruit.Orange]

  • Users will get specific string literal type safety instead of generic string safety: fruit === 'Worm' will "error"

  • Because registerTypes takes an string-object Record, users will be able to parse huge data sets in JSON with less verbosity than defineParameterType (no mapping of name, regex, transformer):

    
    // Retrieved from somewhere
    const data = {
      ProvidersState: {
        PayPal: 'accepted',
        SOFORT: 'unsupported'
      },
      FilmCategoryLabels: {
        Action: 'cool',
        Adventure: 'cooler'
      },
      TestStates: {
        Running: 1,
        Skipped: 0,
        Passed: 2,
        Failed: 3
      }
    }
    
    registerTypes(data)
    



For reference, here's the original, impossible, type-based proposal:


import { When, defineParameterTypes, registerTypes } from '@cucumber/cucumber';

// Possibly imported and re-used from some library or other glue code
type Fruit = 'Apple' | 'Orange' | 'Mango'

// Not what is proposed, but essentially replaces this defineParameterType
defineParameterType({
  regexp: /Apple|Orange|Mango/,
  transformer(s) { return s },
  name: 'Fruit'
})

// Proposal, register types
registerTypes<Fruit, SecondType, ThirdType>()

When('I add an? {Fruit} to my basket', async function (fruit: Fruit) {
  // Declared as putInBasket(fruit: Fruit) { ... }
  putInBasket(fruit)
});

nextlevelbeard avatar Jul 15 '22 14:07 nextlevelbeard

Can you elaborate on how this might be achieved @nextlevelbeard?

TypeScript types are compiler-only and don't exist at runtime, so registerTypes would have no way of seeing them, unless I'm missing something.

davidjgoss avatar Jul 15 '22 15:07 davidjgoss

I also struggled to find how exactly it could be done, just assumed there might be a way. After looking into this it indeed seems impossible.

I think an alternative could be allowing the use of TypeScript's enum.

Users could then easily derive the type in the steps with keyof typeof.

import { Then, defineParameterTypes, registerTypes } from '@cucumber/cucumber';

enum Fruit { 'Apple', Orange, Cucumber }
enum Vegetables { Potato, Eggplant }

registerTypes({ Fruit, Vegetables })

Then('I should have an? {Fruit} in my basket', async function (fruit: keyof typeof Fruit) {
  if(fruit === Fruit[Fruit.Orange]){
    // Assert
  }
}

Internally, we could transform the enum string values into a regular expression and define the parameter type


// Internally implement something like this
Object.values(types).forEach(([name, type]) => defineParameterType({
  name: name, // i.e. Fruit
  // Enum /w numeric keys are not possible, we can safely filter keys
  regexp: new RegExp(Object.keys(type).filter(k => !Number(k)).join('|')), // i.e. Apple|Orange|Cucumber
}))

nextlevelbeard avatar Jul 25 '22 09:07 nextlevelbeard

Maybe you could pull together a TS playground or something that shows this working on a core level?

Again I don't think what you want will exist at runtime. enum values will be there (as the ordinal by default but you can make them a string) but the enum declaration won't exist in the JS as far as I know.

davidjgoss avatar Jul 25 '22 11:07 davidjgoss

Here's the playground, you can run it.

We'd have to implement the registerTypes function and export it for users, same as defineParameterType.

Again I don't think what you want will exist at runtime. enum values will be there (as the ordinal by default but you can make them a string) but the enum declaration won't exist in the JS as far as I know.

With enum this works because "Enums are one of the few features TypeScript has which is not a type-level extension of JavaScript". You can check what enums turn into on the playground.

@davidjgoss Let me know your thoughts on this

nextlevelbeard avatar Jul 25 '22 17:07 nextlevelbeard

Adding more eyes on this @aslakhellesoy @mattwynne

nextlevelbeard avatar Aug 01 '22 19:08 nextlevelbeard

I'm a bit uneasy about adding something that's geared specially around a TypeScript concept. I think plain old arrays would be fine though e.g.

defineSimpleParameterTypes({
  Fruit: ['Apple', 'Orange', 'Cucumber'],
  Vegetables: ['Potato', 'Eggplant']
})

And with TS it's fairly trivial to define a type from an array so not much extra overhead.

davidjgoss avatar Feb 04 '23 12:02 davidjgoss

I'm confused about why this is useful. Can I see a more realistic example?

mattwynne avatar Feb 08 '23 05:02 mattwynne

I'm a bit uneasy about adding something that's geared specially around a TypeScript concept. I think plain old arrays would be fine though e.g.

This wouldnn't take anything away from existing users, it's just making it simpler working with TS enums. You can easily add this registerTypes function to any existing setup. But I agree arrays could be a better approach.

The arrays would need to be readonly in order for users to be able to reference the parameter type in the steps. Would look something like this maybe.


// Proposal, a function that takes an array of strings and defines type parameters
const registerTypes = (types: Record<string, readonly string[]>) => {
  Object.entries(types).forEach(([name, type]) => defineParameterType({
    name: name, // i.e. Fruit
    // Enum /w numeric keys are not possible by TypeScript design, we can safely filter keys
    regexp: new RegExp(type.filter(k => !Number(k)).join('|')), // i.e. Apple|Orange|Cucumber,
    transformer: (s: string) => s
  }))
}

import { Then, registerTypes } from '@cucumber/cucumber';

const Fruit = ['Apple', 'Orange', 'Cucumber'] as const
const Vegetables = ['Potato', 'Eggplant'] as const

registerTypes({ Fruit, Vegetables })

Then('I should have an? {Fruit} in my basket', stepFn = async function (fruit: typeof Fruit[number]) {

  // Type safety, IDE complains about impossible comparisons
  if(fruit === "asd"){

  }
})

I'm confused about why this is useful. Can I see a more realistic example?

  1. Makes working with many multiple choice parameter data simpler by declaring and re-using the options as types
  2. Simplifies the use of defineParameterType for simple cases
  3. Makes parameters offer type-safety when doing comparisons in steps.

nextlevelbeard avatar Feb 20 '23 15:02 nextlevelbeard