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

[Feature] @xstate/react : snapshot testing of components by state #432

Closed
jmyrland opened this issue Apr 20, 2019 · 15 comments
Closed

[Feature] @xstate/react : snapshot testing of components by state #432

jmyrland opened this issue Apr 20, 2019 · 15 comments

Comments

@jmyrland
Copy link

First off, great work! 👏

(Feature) Description

I would like to see a feature for testing snapshots of a component by state, like react-automata.

(Feature) Potential implementation:

Example usage:

import { testStateMachine } from '@xstate/react'
// Optionally
// import testStateMachine from '@xstate/react/test-state-machine'
import App from './App'

test('it works', () => {
  testStateMachine(App)
})

The testStateMachine method should:

I would like to help with this feature, but I am not sure how to "get" the machine from within the component utilizing the useMachine hook. I would gladly contribute with some guidance!

@jmyrland jmyrland changed the title testStateMachine option for @xstate/react [Feature] @xstate/react : snapshot testing of components by state Apr 20, 2019
@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

I am also interested in this feature, and exactly because I want to be able to save snapshots of my app state for testing. xstate features multiple times that it can jump to (i.e. reload) a snapshot of a state:

https://xstate.js.org/docs/guides/states.html#persisting-state

// This will start the service at the specified State
const service = interpret(myMachine).start(resolvedState);

https://xstate.js.org/docs/guides/interpretation.html#batched-events

Batched events are useful for event sourcing approaches. A log of events can be stored and later replayed by sending the batched events to a service to arrive at the same state.

and indeed I have some tests that look like

it('Transition from A to B', done => {
    const machine = interpret(Machine).start(
      createStateObject([X, A])
    )

    machine.onTransition(() => {
      if (
        matchState(machine, [B, C])
      ) {
        done()
      }
    })
    expectState(machine, [B, C])
    machine.send(COLLECT_RESULTS, {
      numbers: Array(10).fill(4)
    })
  })

this works because I can manipulate the initial state with .start() (and also .withContext(), to cover injecting extended state as well).

But this does not cover my react components, which wrap xstate StateMachines. Indeed, if you read the source for @xstate/react you see that it only ever calls service.start(), and does not expose any way to pass arguments in, which is exactly the problem you raised: there is no way to get direct access to the running machine.

useMachine() provides a context: argument. But all it does is a simple merge {...machine.context, ...context} and there's StateMachine.withContext() for that already anyway, so anywhere I can do const [current, send] = useMachine(m, context: { color: 'red' }) I could just as well -- and more consistent-with-the-rest-of-the-API-ly -- do const [current, send] = useMachine(m.withContext({color: 'red'}).

So that sets current.context, but there's no equivalent way to initialize current.value!

I propose the @xstate/react API should either grow a parameter initial

const [ current, send ] = useMachine(m, initial: 'middle_of_the_road`, context: { surprise: 'scarecrow', gold: 32 } );

which gets fed into service.start(), or the main xstate StateMachine API should grow a .withInitial() method or have some other way to initialize the initial state.

@davidkpiano
Copy link
Member

You can already do this with useService(...), which is for services that are already running:

// ...
const myService = useMemo(() => interpret(myMachine).start(myInitialState), []);
const [current, send] = useService(myService);

// ...

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019 via email

@davidkpiano
Copy link
Member

but I'm wondering if it is possible to assign the state directly.

You mean after the machine is started? This would be contradictory to how state machines work.

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

I would be okay with doing it at initialization. I just can't because this line

https://github.com/davidkpiano/xstate/blob/a3215b7442cae379c84325a56bdac6e87bc0b385/packages/xstate-react/src/index.ts#L81-L84

does not expose the arguments to .start() to me as a user of @xstate/react.

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

but I'm wondering if it is possible to assign the state directly.

You mean after the machine is started? This would be contradictory to how state machines work.

Doesn't https://xstate.js.org/docs/guides/interpretation.html#starting-and-stopping suggest doing just that?

Services can be started from a specific state by passing the state into service.start(state). This is useful when rehydrating the service from a previously saved state.

// Starts the service from the specified state,
// instead of from the machine's initial state.
service.start(previousState);

@davidkpiano
Copy link
Member

Doesn't xstate.js.org/docs/guides/interpretation.html#starting-and-stopping suggest doing just that?

It does not. The difference is that you are starting from a previous state, instead of arbitrarily putting an already-running machine into a specified state.

You're looking at useMachine - if you want a service started at a specific state, useService() is what you'd use (see my previous example).

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

Oh! I didn't realize you were suggesting replacing useMachine() with your two lines. I tried them in my app and they worked out great. Thank you!

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

It is pretty unintuitive that useMachine() which creates a running state machine cannot be used to fully initialize that machine. It would be a lot clearer to only have useMachine() that does take initial as an argument or useService() and force people to pre-initialize their own services.

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

Ah, I spoke too soon. If I use your suggestion with this React component:

<DAG
    machine={machines[current.value]}
    account={current.context.account}
    goto={"..."}
    exit={() => {
       send('EXIT')
    }}
/>

like so:

export const DAG: React.FC<DAGProps> = props => {
  const [current, send, service] = useService(useMemo(() => interpret(props.machine.withContext({
    ...(props.machine.context as DAGContext),
    account: props.account
  })).start(props.goto), []));
  
  ...
switch (current.value) {
   ...
    case DAGState.RepairState:
      return (
        <RepairShop wheels={current.context.mountshop.bikesize} />
      )
  ...

And pass in "RepairStep" for goto (which is a valid StateValue), or any other StateValue that isn't the expected initial state, then I get

TypeError: Cannot read property 'mountshop' of undefined

the reason for this is that in

https://github.com/davidkpiano/xstate/blob/a3215b7442cae379c84325a56bdac6e87bc0b385/src/interpreter.ts#L409-L415

passing an initialState value as a string hits the case

        : this.machine.resolveState(State.from(initialState));

which drops the default machine.context, leaving it just straight up undefined. I think that line needs to become:

        : this.machine.resolveState(State.from(initialState, this.machine.context));

I'll try reloading a persisted state next.

@kousu
Copy link
Contributor

kousu commented Jul 26, 2019

I saved some state using JSON.stringify() like suggested at https://xstate.js.org/docs/guides/states.html#persisting-state and changed my call to

const persisted_state = `....`
...
            <DAG
              machine={machines[current.value]}
              account={current.context.account}
              goto={(State.create(JSON.parse(persisted_state, (key, value) => value.type == "Buffer" ? Buffer.from(value.data) : value )))}
              exit={() => {
                send('EXIT')
              }}
            />

and it worked on the third try.

I haven't found the need to call machine.resolveState() like suggested in the docs. I would rather not have to bind my state to a specific machine, at least not until the last possible moment because it complicates the code; do you think that is going to bite me?

(the Buffer.from() business is because JSON.parse() can't handle Buffers properly and my app uses a few of them.)

@ccontreras
Copy link

ccontreras commented Sep 23, 2019

Hi @davidkpiano ,

Can I do something similiar with actors?,

I'm doing something like this for actors but it looks that the spawn starts the machine intrinsically as well as the useMachine.

This is my custom spawning implementation:

export const spawnPersisted = <TContext, TEvent extends EventObject>(
  machine: StateMachine<TContext, any, TEvent>,
  name: string
): Actor<TContext> => {
  if (process.browser) {
    const state = window.sessionStorage.getItem(name);
    if (!!state) {
      const jsonState = JSON.parse(state);
      machine.resolveState(jsonState);
    }
  }
  return spawn(machine, name);
};

@davidkpiano
Copy link
Member

Can I do something similiar with actors?,

Sorry, can you explain your issue further? What are you trying to accomplish exactly?

@ccontreras
Copy link

Hi @davidkpiano ,

I'm trying to set an initial state for the actor's machine coming from the session storage after being previously saved. Since an actor interpreter can't be serialize (it resolves to { id: 'actor-id' } when JSON serialize), there should be a way to restore it.

So, in the machine I have this:

 Machine<AppointmentContext, AppointmentSchema, AppointmentEvent>(
  {
    id: 'appointment',
    initial: 'initializing',
    states: {
      initializing: {
        entry: assign({
          schedulingRef: () => spawnPersisted(schedulingMachine, 'scheduling'),
          personalRef: () => spawnPersisted(personalInfoMachine, 'personal'),
        }),
        on: {
          '': 'scheduling',
        },
      },
   },
  ...
})

Do you have any suggestion for me to accomplish this?

@davidkpiano
Copy link
Member

This isn't an easy problem, because you can't guarantee that the spawned actor will be in the same state that it was at (in fact, it's easier to guarantee that it won't). However, in 4.7 I will be introducing a meta property to the Actor interface, that will give as many details about that actor as possible to help you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants