Shopify – Kinane https://djang0.dev Mon, 25 Nov 2024 23:18:29 +0000 en-US hourly 1 https://wordpress.org/?v=6.3.2 https://djang0.dev/wp-content/uploads/2021/10/code-solid.svg Shopify – Kinane https://djang0.dev 32 32 Implementing an effective billing system using Finite State Machines https://djang0.dev/development/implementing-a-billing-system-using-finite-state-machines/ Fri, 08 Nov 2024 22:13:06 +0000 https://djang0.dev/?p=1118 Reading Time: 5 minutes Implementing a whole billing system for Shopify, backend and frontend using XState and the actor model]]> Reading Time: 5 minutes
  • This post is intended for implementing a recurring billing system for Shopify; if you're looking for an OOB alternative, check Mantle (it's dope). I did this code before Mantle was as good as it is right now, but it's still generically useful (if there's no mantle in your niche)
  • It's better if you have already implemented a billing system and, more importantly, if you understand XState and the Actor Model (I want to have the same PTSD as my readers 👉👈)
  • Check this article on a large screen because you're going to eat a lot of stately chart.s

Understanding the problematic

Implementing a billing system sucks; you've to manage a lot of edge cases like this smart guy says:

What happens if you want to handle:

image 1
(it's not; it's delicious pls trust me)
  • Discount?
  • Upgrade/Downgrade?
  • What happens during app uninstall?
  • Jobs, can you check if the subscription is still active?
  • Remember how it works when you return to this random codebase 1 month later?
  • Making things evolve easily
  • Centralizing your logic
  • Removing all boring side effects?
  • Sort of composition?
  • Using the same mental model front/back?
  • Even managing the Front? (bonus 🥸🥸🥸)

Answer: Finite State Machines + Actor Model

Definitions

Entities

Here are the entities we'll use for managing our billing system

Recurring Application Charge (RAC)

It represents a recurring charge (gg wp), which is linked to an appPlan and maybe an appDiscount

It could be active or not; only one RAC can be active, BUT a shop can have many RACs (pending, canceled etc.)

TypeScript
export const RecurringApplicationCharge = z
  .object({
    id: z.string(),
    name: z.string(), // refer to AppPlan.name
    price: z.number().int().min(0),
    appDiscountId: z.string().uuid().nullable(),
    createdReason: CreateDeleteRacReasonEnum,
    billingOn: zDate.nullable(),
    status: RacStatusEnum,
    activatedOn: zDate.nullable(),
    returnUrl: z.string(),
    test: z.boolean(),
    cancelledOn: zDate.nullable(),
    cancelledReason: CreateDeleteRacReasonEnum.nullable(),
    isCancellationProrated: z.boolean().nullable(),
    trialDays: z.number().int().min(0),
    trialEndsOn: zDate.nullable(),
    shopifyShopId: z.string(),
    apiClientId: z.string(),
    currency: z.enum(["USD", "AUD", "EUR", "INR", "SGD", "GBP", "CAD"]),
  })
  .merge(BaseShopifyEntitiesKeys)

appPlan

Representing the plans with intervals available in the app

TypeScript
export const AppPlan = z.object({
  id: z.string().uuid(),
  name: z.string(),
  slug: z.string(),
  trialDays: z.number().int(),
  interval: z.enum(["EVERY_30_DAYS", "ANNUAL", "LIFETIME"]),
  price: z.number().int(),
  i18nFeatures: z.record(z.string(), z.array(z.string())),
  description: z.record(z.string(), z.string()),
  currencyCode: z.enum(["USD", "AUD", "EUR", "INR", "SGD", "GBP", "CAD"]),
  updatedAt: zDate,
  createdAt: zDate,
  activeSizeChartsLimit: z.number().int().min(-1),
  includedShopifyPlans: z.array(FullPlanTypes),
  testToProdGracePeriodDays: z.number().int().min(0),
  features: z.record(
    z.string(),
    z.object({
      enabled: z.boolean(),
    }),
  ),
  type: z.enum(["common", "fallback", "custom"]),
  watermarkStatus: WatermarkStatus,
})

appDiscount

Represent a discount, can be allowed to N plans

TypeScript
export const AppDiscount = z.object({
  id: z.string(),
  allowedPlanSlugs: z.array(z.string()),
  description: z.string(),
  discountCode: z.string(),
  discountUsageLimit: z.number().min(-1),
  discountUsed: z.number().min(0),
  discountValue: z.number().min(0),
  discountType: z.enum(["percentage", "fixed_amount"]),
  durationLimitInterval: z.number().min(-1),
  createdAt: zDate,
  updatedAt: zDate,
})

Relations

image
Implementing an effective billing system using Finite State Machines 5

Processing the Subscription

Initializing the subscription creation

The idea is to reach the shopify paywall before approving the billing in a safe condition:

We can first simplify the machine:

You can click on simulate and on the current state to test the workflow. Also, check the state's description because it can explain what it does.

So we have, again you can check the description on stately :

  1. Validating, which checks if the shop can initialize the charge
  2. creatingSubscription which does a call to Shopify to initialize the payment
  3. If it's done => success; otherwise error

It's pseudo-machine that describes the key steps of initializing the subscription, with the key steps written down.

Let's add a bit more complexity here and create the initCreateSubscriptionMachine (take some time to check it; there's a description for each state)

I'm not commenting since it should be descriptive (I mean, that's the goal 🥸)

Of course, it takes time to get used to Stately‘s representation, there's arrows everywhere + it looks bloated, but even non-techies can UNDERSTAND this BEAUTY with time.

So far, so good. We have the first step, and we generated this appSubscription and we get the callbackUrl to validate the payment

So now the user is redirected here. We have the RAC persisted etc. etc cool, nice THANKS MOM

CleanShot 2024 11 08 at 20.38.01@2x
Implementing an effective billing system using Finite State Machines 6

Let's go for the second part when the user clicks on approve (remember this for later)

Managing the billing confirmation

We get back the webhook app_subscriptions/update who'll trigger the confirmSubscriptionMutation:

It's pretty sequential, yes. We retrieve the RAC from Shopify, persist, and sync. There's just one thing here, confirmSubscriptionMutation invoke a syncShopAppPlanMachine in syncShopAppPlanToRemote state that does a synchronization to Shopify (updating a billing metafield to remove the watermark)

🚨 So it's a machine invoking a machine 👉 an actor invoking an actor AND that's the power of XState

In this case, It induces a sort of composability because we only invoke it (it's like fire/forget), but it's more than that

Updating back to Shopify

We retrieve data and update a shopify metafield accordingly, using a machine again syncShopAppPlanMachine

Liquid
{% liquid
    assign billingConfigRaw = app.metafields.rsc-app.billing_config.value
    assign billingConfig = billingConfigRaw | split: ","
    assign appPlanSlug = billingConfig | where: "appPlanSlug" | first | split: ":" | last
    assign watermarkStatus = billingConfig | where: "watermarkStatus" | first | split: ":" | last
    assign activeSizeChartsLimit = billingConfig | where: "activeSizeChartsLimit" | first | split: ":" | last | plus: 0
%}
image 2
By updating this metafield based on the plan, we remove the watermark on the liquid part

Combining the full workflow

Now that we have divided each step, we can merge back into a single machine based on the previous machines to create kind of a stateful workflow.

  • Waiting wait until an event is fired
  • on event billing.create.init we save the subscription on the context and transit to InitAppSubscription
  • InitAppSubscription fire the first machine we talked about initCreateSubscriptionMachine
  • on event subscription.create.confirm (which is when a user approves) we trigger confirmSubscriptionMutationMachine

And so, finally, we have the full workflow 😎:

By splitting key parts into machines (init, confirm, pushing back to Shopify), we created a whole composable, upgradeable, and more importantly, READABLE (yes, I swear you're going to be used to this Stately madness) set of machines ready to use for different intents.

Bonus: Managing the app Frontend with an FSM using the inspector

Let's continue with one other bloated schema, which is managing the front plan page with FSM; thanks to Stately Inspector, you can see the Sequence Diagram of each event triggered

Related machine:

OF COURSE, this machine can be tested with Model Based Testing based on the definition itself! 😎

Conclusion

We created a declarative and centralized workflow to manage a simple billing system with XState and composability in mind.

Again, getting used to XState Stately takes time, but it's worth the price, especially if you work as a team.

There are advantages in clarity and minimizing your mental load using XState.

There are also cons, like sometimes coding itself, the shape of the machine can be overwhelming (for instance, you should use a guard while a pattern matcher could've been more straightforward, or just if/else). That's the price of being able to represent a workflow; you should follow common rules.

]]>
Initialize the subscription of the billing system nonadult
Model-Based Testing for a Secure Shopify Theme with XState https://djang0.dev/development/model-based-testing-to-shopify-theme/ https://djang0.dev/development/model-based-testing-to-shopify-theme/#respond Tue, 17 Oct 2023 12:10:33 +0000 https://djang0.dev/?p=937 Reading Time: 7 minutes Discussing testing methods with Model-Based Testing and trying to simulate a customer workflow based on key steps in his journey using Playwright and FSM.]]> Reading Time: 7 minutes

The repository is accessible and has been updated with the latest major XState version.

Model-based testing (MBT) involves creating a model that describes how a system should work and then using this model to generate tests automatically. The model is often a state machine, representing the stages or states the system can reach and the transitions between them. Ok this a bit boring, A concrete example would be to represent the different possible paths of a user on an e-commerce site (a bit like when you spy on Hotjar and see the navigation steps of a customer).

Basic Example

Simple representation of a basic machine which goes to home page and then shop all page

Those charts are representations from Stately; it's generated based on my machine's code, there's also a vscode plugin to represent your machine

From this model, we can derive the states and transitions:

States

  1. onHomePage: User will be redirected to the Home Page
  2. inShopAllPage: The user is on the “Shop All” page.

Events

  1. HEADER_MENU_GO_TO_HOME_PAGE: Transition from any page back to the home page via the header menu.
  2. HERO_GO_TO_SHOP_ALL: Transition from the home page to the “Shop All” page via the hero button or section.

With these events and states, we can impute the playwrights' related actions.

{
  states: {
    onHomePage: async () => {
      await page.goto(`${baseShopUrl}/`);
    },
    inShopAllPage: async () => {
      await page.waitForURL(`${baseShopUrl}/collections/all`);
    },
  },
  events: {
    HERO_GO_TO_SHOP_ALL: async () => {
      const heroButton = await page.$(".banner a");
      if (!heroButton) throw new Error("Hero button not found");
      await heroButton.click();
    },
    HEADER_MENU_GO_TO_HOME_PAGE: async () => {
      const homeButton = await page.$("#HeaderMenu-home");
      if (!homeButton) throw new Error("Home button not found");
      await homeButton.click();
    },
  },
}

How many paths (and thus tests) do you think this will generate? 1? 2? even more?

Answer: Only one 😅

In MBT, each state is unique, so the return path is not set up as a standard

To do so, we need to create a “unique” state:

CleanShot 2023 10 18 at 11.40.25@2x
Model-Based Testing for a Secure Shopify Theme with XState 11

Here we go, using @xstate/test and playwright

import { test } from "@playwright/test"
import { createTestMachine, createTestModel } from "@xstate/test"
import { e2eConfig } from "./config"

const machine = createTestMachine({
  id: "basicHomePage",
  initial: "onHomePage",
  states: {
    onHomePage: {
      on: {
        HERO_GO_TO_SHOP_ALL: {
          target: "#basicHomePage.inShopAllPage",
          reenter: false,
        },
      },
    },
    inShopAllPage: {
      on: {
        HEADER_MENU_GO_TO_HOME_PAGE: {
          target: "#basicHomePage.inHomePageDestination",
          reenter: false,
        },
      },
    },
    inHomePageDestination: {
      type: "final",
    },
  },
})

createTestModel(machine)
  .getSimplePaths()
  .forEach((path) => {
    test(path.description, async ({ page }) => {
      await path.test({
        states: {
          onHomePage: async () => {
            await page.goto(`${e2eConfig.localBaseShopUrl}/`)
          },
          inHomePageDestination: async () => {
            await page.waitForURL(`${e2eConfig.localBaseShopUrl}/`)
          },
          inShopAllPage: async () => {
            await page.waitForURL(`${e2eConfig.localBaseShopUrl}/collections/all`)
          },
          inShopAllPageDestination: async () => {
            await page.waitForURL(`${e2eConfig.localBaseShopUrl}/`)
          },
        },
        events: {
          HERO_GO_TO_SHOP_ALL: async () => {
            const heroButton = await page.$(".banner a")
            if (!heroButton) throw new Error("Hero button not found")
            await heroButton.click()
          },
          HEADER_MENU_GO_TO_HOME_PAGE: async () => {
            const homeButton = await page.$("#HeaderMenu-home")
            if (!homeButton) throw new Error("Home button not found")
            await homeButton.click()
          },
        },
      })
    })
  })

In this way, We go back to the first theoretical state onHomePage:

  1. The user starts on the home page (onHomePage).
  2. The user clicks on the hero button or section (HERO_GO_TO_SHOP_ALL).
  3. The user is navigated to the “Shop All” page (inShopAllPage).
  4. The user then clicks on the header menu to return to the home page (HEADER_MENU_GO_TO_HOME_PAGE).
  5. The user ends back on the home page (inHomePageDestination).

onHomePage -> HERO_GO_TO_SHOP_ALL -> inShopAllPage -> HEADER_MENU_GO_TO_HOME_PAGE -> inHomePageDestination

Round-trip result 🏎️

Declarative > Imperative

By specifying the various states and events, we give control over the generation of test steps to the model, rather than making it explicit ourselves.

Advanced Structure using Model-Based Testing on a Cart Drawer

We can now generate the user workflow that wants, for instance, to buy a product using different paths

Model-Based testing stately representation on a shopify Cart Drawer
Model-Based Testing for a Secure Shopify Theme with XState 12
import { expect, test } from "@playwright/test"
import { createTestModel } from "@xstate/test"
import { setup } from "xstate"
import { e2eConfig } from "./config"
import { genPwMetas, generateXstateStateAndEventsFromPw } from "./utils"

const singleProductPageBaseUrl = `${e2eConfig.localBaseShopUrl}/products/the-3p-fulfilled-snowboard`

const advancedAddToCartMachine = setup({
  types: {
    events: {} as
      | {
          type: "ADD_TO_CART"
        }
      | {
          type: "BUY_NOW"
        }
      | {
          type: "SHOW_CART"
        }
      | {
          type: "CLICK_ON_VIEW_CART_PAGE_BUTTON"
        }
      | {
          type: "CLICK_ON_HIDE_CART"
        }
      | {
          type: "CLICK_ON_CART_ICON"
        }
      | {
          type: "CHECK_PRODUCT_ADDED_TO_CART"
        }
      | {
          type: "GO_TO_CHECKOUT"
        },
  },
}).createMachine({
  initial: "onProductPage",
  id: "atcMachine",
  context: {},
  states: {
    // This act as our entrypoint
    onProductPage: {
      on: {
        ADD_TO_CART: "cartDrawer.visible",
        BUY_NOW: "inCheckout",
      },
    },
    cartDrawer: {
      initial: "hidden",
      states: {
        hidden: {
          on: {
            CLICK_ON_CART_ICON: "#atcMachine.inCartPage",
          },
        },
        visible: {
          on: {
            CLICK_ON_HIDE_CART: "hidden",
            CLICK_ON_CART_ICON: "#atcMachine.inCartPage",
            CLICK_ON_VIEW_CART_PAGE_BUTTON: "#atcMachine.inCartPage",
          },
        },
      },
    },
    productAddedToCart: {
      on: {
        GO_TO_CHECKOUT: "inCheckout",
      },
    },
    inCartPage: {
      on: {
        GO_TO_CHECKOUT: "inCheckout",
      },
    },
    inCheckout: {
      type: "final",
    },
  },
})

// Not quite sure of this pattern compared to simply use `meta`
const gen = genPwMetas<typeof advancedAddToCartMachine>((page) => {
  return {
    states: {
      inCartPage: {
        meta: {
          urlRegex: new RegExp(
            `^${e2eConfig.localBaseShopUrl.replace(/[-\/\^$*+?.()|[\]{}]/g, "\\$&")}/cart`,
            "i",
          ),
          waitFor: true,
          expectUrl: true,
        },
      },
      onProductPage: {
        decoratedHandler: async () => {
          await page.goto(singleProductPageBaseUrl)
          await page.waitForURL(singleProductPageBaseUrl)
        },
      },
      "cartDrawer.visible": async () => {
        await page.waitForSelector("#cart-notification")
        const cartDrawer = await page.$("#cart-notification")
        if (!cartDrawer) throw new Error("Cart drawer not found")
        const isActive = cartDrawer.evaluate((node) => node.classList.contains("active"))
        expect(isActive).toBeTruthy()
      },
      addedToCart: async () => {
        const cartItem = await page.$("#cart-notification-product")
        if (!cartItem) throw new Error("Cart item not found")
        const titleText = await cartItem.evaluate(async (node) => {
          const title = node.querySelector("h3")
          if (!title) throw new Error("Cart item title not found")
          return title.textContent
        })
        expect(titleText).toBe("The 3P Fulfilled Snowboard")
      },
      inCheckout: {
        meta: {
          urlRegex: new RegExp(
            `^(?:${e2eConfig.localBaseShopUrl.replace(
              /[-\/\^$*+?.()|[\]{}]/g,
              "\\$&",
            )}|${e2eConfig.webBaseShopUrl.replace(
              /[-\/\^$*+?.()|[\]{}]/g,
              "\\$&",
            )})/checkouts/.*$`,
            "i",
          ),
          waitFor: true,
          expectUrl: true,
        },
      },
      hidden: {},
    },
    events: {
      ADD_TO_CART: async () => {
        const addToCartButton = await page.$('button[name="add"]')
        if (!addToCartButton) throw new Error("Add to cart button not found")
        await addToCartButton.click()
      },
      GO_TO_CHECKOUT: async () => {
        const goToCheckoutButton = await page.$("#checkout")
        if (!goToCheckoutButton) throw new Error("Go to checkout button not found")
        await goToCheckoutButton.click()
      },
      BUY_NOW: async () => {
        const buyNowButton = await page.$(
          ".shopify-payment-button__button.shopify-payment-button__button--unbranded",
        )
        if (!buyNowButton) throw new Error("Buy now button not found")
        await buyNowButton.click()
      },
      SHOW_CART: async () => {
        const cartButton = await page.$("#cart-notification-button")
        if (!cartButton) throw new Error("Cart button not found")
        await cartButton.click()
      },
      CLICK_ON_VIEW_CART_PAGE_BUTTON: async () => {
        const viewCartPageButton = await page.$("#cart-notification-button")
        if (!viewCartPageButton) throw new Error("View cart page button not found")
        await viewCartPageButton.click()
      },
      CLICK_ON_HIDE_CART: async () => {
        const hideCartButton = await page.$(".cart-notification__close")
        if (!hideCartButton) throw new Error("Hide cart button not found")
        await hideCartButton.click()
      },
      CLICK_ON_CART_ICON: async () => {
        const cartIcon = await page.$("#cart-icon-bubble")
        if (!cartIcon) throw new Error("Cart icon not found")
        await cartIcon.click()
      },
    },
  }
})

createTestModel(advancedAddToCartMachine)
  .getSimplePaths()
  .forEach((path) => {
    test(path.description, async ({ page }) => {
      await path.test(generateXstateStateAndEventsFromPw(gen(page), page))
    })
  })

And generate a tiny factory for the related events and states to bind the playwright's logic:

const advancedAddToCartMachinePlaywrightStatesAndEventsFactory = (
  page: Page
): StateAndEvents => {
  return {
    states: {
      inCartPage: {
        decoratedHandler: async () => {},
        meta: {
          urlRegex: new RegExp(
            `^${baseShopUrl.replace(/[\/\^$*+?.()|[\]{}-]/g, "\\$&")}/cart`,
            "i"
          ),
          waitFor: true,
          expectUrl: true,
        },
      },
      onProductPage: {
        decoratedHandler: async () => {
          await page.goto(singleProductPageBaseUrl);
          await page.waitForURL(singleProductPageBaseUrl);
        },
      },
      "cartDrawer.visible": async () => {
        await page.waitForSelector("#cart-notification");
        const cartDrawer = await page.$("#cart-notification");
        if (!cartDrawer) throw new Error("Cart drawer not found");
        const isActive = cartDrawer.evaluate((node) =>
          node.classList.contains("active")
        );
        expect(isActive).toBeTruthy();
      },
      addedToCart: async () => {
        const cartItem = await page.$("#cart-notification-product");
        if (!cartItem) throw new Error("Cart item not found");
        const titleText = await cartItem.evaluate(async (node) => {
          const title = await node.querySelector("h3");
          if (!title) throw new Error("Cart item title not found");
          const titleText = title.textContent;
          return titleText;
        });
        expect(titleText).toBe("The 3P Fulfilled Snowboard");
      },
      inCheckout: {
        meta: {
          urlRegex: new RegExp(
            `^${baseShopUrl.replace(
              /[-\/\^$*+?.()|[\]{}]/g,
              "\\$&"
            )}/checkouts/.*$`,
            "i"
          ),
          waitFor: true,
          expectUrl: true,
        },
      },
      hidden: {},
    },
    events: {
      ADD_TO_CART: async () => {
        const addToCartButton = await page.$('button[name="add"]');
        if (!addToCartButton) throw new Error("Add to cart button not found");
        await addToCartButton.click();
      },
      GO_TO_CHECKOUT: async () => {
        const goToCheckoutButton = await page.$('button[name="checkout"]');
        if (!goToCheckoutButton)
          throw new Error("Go to checkout button not found");
        await goToCheckoutButton.click();
      },
      BUY_NOW: async () => {
        const buyNowButton = await page.$(
          ".shopify-payment-button__button.shopify-payment-button__button--unbranded"
        );
        if (!buyNowButton) throw new Error("Buy now button not found");
        await buyNowButton.click();
      },
      SHOW_CART: async () => {
        const cartButton = await page.$("#cart-notification-button");
        if (!cartButton) throw new Error("Cart button not found");
        await cartButton.click();
      },
      CLICK_ON_VIEW_CART_PAGE_BUTTON: async () => {
        const viewCartPageButton = await page.$("#cart-notification-button");
        if (!viewCartPageButton)
          throw new Error("View cart page button not found");
        await viewCartPageButton.click();
      },
      CLICK_ON_HIDE_CART: async () => {
        const hideCartButton = await page.$(".cart-notification__close");
        if (!hideCartButton) throw new Error("Hide cart button not found");
        await hideCartButton.click();
      },
      CLICK_ON_CART_ICON: async () => {
        const cartIcon = await page.$("#cart-icon-bubble");
        if (!cartIcon) throw new Error("Cart icon not found");
        await cartIcon.click();
      },
    },
  };
}

With some tiny tricks, we can be in pure declarative, trying to further abstract the playwright's logic and start some tests (this is a bit boring and not essential), by using some metadata.

import { test } from "@playwright/test";
import { createTestMachine, createTestModel } from "@xstate/test";

const isAPlaywrightStateFunction = (
  state: PlaywrightState
): state is PlaywrightFunctionState => {
  return isFunction(state);
};
const generateXstateTestStateFromPlaywrightState = (
  playwrightState: PlaywrightState,
  page: Page
): any => {
  if (isAPlaywrightStateFunction(playwrightState)) {
    return async () => {
      await playwrightState();
    };
  }
  const meta = playwrightState.meta;
  return async () => {
    if (playwrightState?.decoratedHandler) {
      await playwrightState.decoratedHandler();
    }
    if (meta?.waitFor && meta.urlRegex) {
      await page.waitForURL(meta.urlRegex);
    }
    if (meta?.expectUrl) {
      expect(page.url()).toMatch(meta.urlRegex);
    }
  };
};
const generateXstateStateAndEventsFromPlaywrightStatesAndEvents = (
  playwrightStatesAndEvents: StateAndEvents,
  page: Page
) => {
  const states = {};
  const events = {};
  Object.keys(playwrightStatesAndEvents.states).forEach((key) => {
    const playwrightState = Reflect.get(playwrightStatesAndEvents.states, key);
    Reflect.set(
      states,
      key,
      generateXstateTestStateFromPlaywrightState(playwrightState, page)
    );
  });
  Object.keys(playwrightStatesAndEvents.events).forEach((key) => {
    const playwrightEvent = Reflect.get(playwrightStatesAndEvents.events, key);
    Reflect.set(events, key, playwrightEvent);
  });
  return {
    states,
    events,
  };
};

createTestModel(advancedAddToCartMachine)
  .getSimplePaths()
  .forEach((path) => {
    test(path.description, async ({ page }) => {
      await path.test(
        generateXstateStateAndEventsFromPlaywrightStatesAndEvents(
          advancedAddToCartMachinePlaywrightStatesAndEventsFactory(page),
          page
        )
      );
    });
  });

This gives us five paths to be tested:

CleanShot 2023 10 18 at 13.40.39@2x
Model-Based Testing for a Secure Shopify Theme with XState 13

Shortest Paths vs Simplest Paths?

Shortest Paths

  • The shortest path between two points (or states) without passing through the same point twice.
  • Often used to identify the fastest or most efficient path.
  • Does not necessarily cover all test cases or features.

Simplest Paths

  • Any path that passes from a starting point to an end point without revisiting any intermediate point.
  • Unlike “shortest path”, it may not be the shortest path, but it ensures that no node is visited more than once.
  • Can cover more features or test cases than the “shortest path”, as it can take longer or different routes.

Translated with www.DeepL.com/Translator (free version)

Results

The model automatically generated five tests for us with a consistent workflow 🤯

Conclusion

Using a finite state machine and playwright, we generated e2e tests by targeting the key steps of a user journey and creating the various associated paths.

Benefits:

  • Self-generated test (even if GitHub copilot works fine as usual)
  • Ultra-fast maintenance: if you want to add a step along the way, the machine can be changed very quickly.
  • High Test Coverage
  • Easy to automate
  • More reliable tests
  • Explicit docs
  • Helps you to shine in society 🤠

Cons:

  • It hurts to learn
]]>
https://djang0.dev/development/model-based-testing-to-shopify-theme/feed/ 0 Shopify - Kinane nonadult
Why do Shopify Functions provide an unfair advantage to create exceptional stores? https://djang0.dev/development/why-do-shopify-functions-provide-an-unfair-advantage-to-create-exceptional-stores/ https://djang0.dev/development/why-do-shopify-functions-provide-an-unfair-advantage-to-create-exceptional-stores/#respond Fri, 10 Mar 2023 17:21:53 +0000 https://djang0.dev/?p=842 Reading Time: 2 minutes This article will try to summarize my experience with Shopify Functions; here we go and why they provide an unfair advantage to create exceptional stores. ]]> Reading Time: 2 minutes

1 – What is a Shopify Function?

Shopify Function allows Shopify app developers to write custom serverless functions that can be executed in response to certain events or triggers and alter the backend logic of Shopify, opening unlimited possibilities for customizations.

image 4
Why do Shopify Functions provide an unfair advantage to create exceptional stores? 18

e.g., You have an input (for example, a cart), and you return an output (a discount) you want, according to what you have coded in your function depending on the Function API your target. It's like a module in

@make_hq (or your old math lessons): So pretty easy to develop with

image 5
Why do Shopify Functions provide an unfair advantage to create exceptional stores? 19

2 – What are the advantages of Shopify Functions?

  • You are closer to the business logic of Shopify, so you don't need a workaround that will take you a lot of code time to achieve your goals. I made a bundled app based on coupons; now, some logic can be abstracted with Functions.
  • The functions are serverless so Shopify handles all the server load; if you have merchants with huge traffics, the AWS bill will sting less.
  • You can iterate easier. Each function is a brick of your application, more easily maintainable.
  • You can directly use meta fields to store data instead of using your database and storing dynamic values while providing a GUI for merchants
image 6
Why do Shopify Functions provide an unfair advantage to create exceptional stores? 20

3 – How to create Shopify Functions

You must choose a language that compiles in WASM. I came from Typescript, So I chose

@AssemblyScript; you can also use Rust or Golang. It will soon be possible to use Typescript with the Shopify compiler (javy) in production.

image 7
Why do Shopify Functions provide an unfair advantage to create exceptional stores? 21

“Something that cannot be done with Javascript will eventually be done with Javascript.”

Sun Tzu

So if you are patient, wait for JS, even if I think it's better to dig into the documentation and try to code quickly

PS: check @grain_lang actively

Resources

Polyglot Functions based on Discount Tutorial: https://github.com/nickwesselman/polyglot-functions/tree/main/extensions…

The tutorial to understand everything: https://shopify.dev/docs/apps/discounts/experience…

@gadget_dev articles https://gadget.dev/blog/understanding-shopify-functions-part-1…

JS Workshop:

https://workshops.shopify.dev/workshops/javascript-functions-preview#0

Conclusion

Thanks for reading, and I hope you found this article useful! Considering the density of things to say, I'll make several articles on the Shopify Functions.

]]>
https://djang0.dev/development/why-do-shopify-functions-provide-an-unfair-advantage-to-create-exceptional-stores/feed/ 0