React 组件的 Jest 测试:意外的标记“<”

Jest tests on React components: Unexpected token "<"

尝试设置 Jest 来测试我的 React 组件(从技术上讲,我使用的是 Preact)但想法相同...

每当我尝试获取覆盖率报告时,遇到任何 jsx 语法都会出错。

错误

Running coverage on untested files...Failed to collect coverage from /index.js
ERROR: /index.js: Unexpected token (52:2)

  50 |
  51 | render(
> 52 |   <Gallery images={images} />,
     |   ^

我已经尝试按照文档和类似问题进行操作,但没有成功! Jest 似乎没有使用我的 babel 设置。

知道如何消除错误吗?

package.json

{
  "name": "tests",
  "version": "1.0.0",
  "description": "",
  "main": "Gallery.js",
  "scripts": {
    "test": "jest --coverage",
    "start": "parcel index.html"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.5.0",
    "@babel/plugin-proposal-class-properties": "^7.5.0",
    "@babel/plugin-proposal-export-default-from": "^7.5.2",
    "@babel/plugin-transform-runtime": "^7.5.0",
    "@babel/preset-env": "^7.4.5",
    "@babel/preset-react": "^7.0.0",
    "babel-jest": "^24.8.0",
    "babel-plugin-transform-export-extensions": "^6.22.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "enzyme": "^3.10.0",
    "jest": "^24.8.0",
    "jest-cli": "^24.8.0",
    "parcel-bundler": "^1.12.3",
    "react-test-renderer": "^16.8.6"
  },
  "dependencies": {
    "preact": "^8.4.2"
  },
  "jest": {
    "verbose": true,
    "transform": {
      "^.+\.jsx?$": "<rootDir>/node_modules/babel-jest"
    },
    "collectCoverageFrom": [
      "**/*.{js,jsx}",
      "!**/node_modules/**"
    ]
  }
}

.babelrc

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": {
          "node": "current"
        }
      },
      "@babel/preset-react"
    ]
  ],
  "plugins": [
    ["@babel/plugin-transform-runtime", {
      "regenerator": true
    }],
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-proposal-export-default-from",
    "babel-plugin-transform-export-extensions"
  ]
}

编辑

我的组件像这样加载到我的 index.js 文件中:

index.js

import { h, render } from 'preact';
import Gallery from './Gallery'
import "./gallery.css"


const images = [ ... /* Some object in here */ ];

render(
  <Gallery images={images} />,
  document.getElementById('test'),
);

Gallery.js

/** @jsx h */
import { h, Component } from 'preact';

export default class Gallery extends Component {
  constructor(props) {
    super(props);

    // Set initial state
    this.state = {
      showLightbox: false,
    };
  }

  // Handle Keydown function with event parameter
  handleKeyDown = (event) => {
    const { showLightbox } = this.state;
    // If the lightbox is showing
    if (showLightbox) {
      // Define buttons and keycodes
      const firstArrow = document.querySelector('.lightbox .arrows .arrows__left');
      const lastArrow = document.querySelector('.lightbox .arrows .arrows__right');
      const closeIcon = document.querySelector('.lightbox .close-button');
      const TAB_KEY = 9;
      const ESCAPE_KEY = 27;
      const LEFT_ARROW = 37;
      const RIGHT_ARROW = 39;
      // If esc is clicked, call the close function
      if (event.keyCode === ESCAPE_KEY) this.onClose();
      // If left arrow is clicked, call the changeImage function
      if (event.keyCode === LEFT_ARROW) this.changeImage(event, -1);
      // If left arrow is clicked, call the changeImage function
      if (event.keyCode === RIGHT_ARROW) this.changeImage(event, 1);
      // If tab is clicked, keep focus on the arrows
      if (event.keyCode === TAB_KEY && !event.shiftKey) {
        if (document.activeElement === firstArrow) {
          event.preventDefault();
          lastArrow.focus();
        } else if (document.activeElement === lastArrow) {
          event.preventDefault();
          closeIcon.focus();
        } else {
          event.preventDefault();
          firstArrow.focus();
        }
      }
      if (event.keyCode === TAB_KEY && event.shiftKey) {
        if (document.activeElement === firstArrow) {
          event.preventDefault();
          closeIcon.focus();
        } else if (document.activeElement === lastArrow) {
          event.preventDefault();
          firstArrow.focus();
        } else {
          event.preventDefault();
          lastArrow.focus();
        }
      }
    }
  }

  // onClick function
  onClick = (e, key) => {
    // Prevent default action (href="#")
    e.preventDefault();
    /*
      Set state:
        activeImage = the image's index in the array of images
        showLightbox = true

      Callback:
        - Get left arrow button and focus on it
        - Add no scroll class to body
        - Call scrollToThumb function
    */
    this.setState({
      activeImage: key,
      showLightbox: true,
    }, () => {
      document.querySelector('.lightbox .arrows .arrows__left').focus();
      document.body.classList.add('no-scroll');
      this.scrollToThumb();
    });
  }

  // onClose function
  onClose = () => {
    /*
      Set state:
        showLightbox = false

      Callback:
        - Remove no scroll class from body
    */
    this.setState({
      showLightbox: false,
    }, () => document.body.classList.remove('no-scroll'));
  }

  // / changeImage function
  changeImage = (e, calc) => {
    const { activeImage } = this.state;
    const { images } = this.props;
    let newCalc = calc;
    // If first image is active and parameter is -1
    if (activeImage === 0 && calc === -1) {
      // set parameter to the length of the array to go right to the last image
      newCalc = images.length - 1;
    } else if (activeImage === (images.length - 1) && calc === 1) {
      // If last image is active and parameter is 1
      // set parameter to the (negative)length of the array to go right to the first image
      newCalc = -(images.length - 1);
    }
    /*
      Set state:
        activeImage = selected image + or - calc amount

      Callback:
        - Call scrollToThumb function
    */
    this.setState(state => ({
      activeImage: state.activeImage + newCalc,
    }), () => this.scrollToThumb());
  }

  // scrollToThumb function
  scrollToThumb = () => {
    /* Define variables for:
      - Lightbox div
      - Thumbs div
      - First thumbnail div
      - Active thumbnail div
      - The offsetTop of the clicked thumbnail on mobile devices
      - X-axis offset of first div
    */
    const lightbox = document.querySelector('.lightbox');
    const thumbs = document.querySelector('.thumbs');
    const firstThumb = document.querySelectorAll('.thumb')[0];
    const activeThumb = document.querySelector('.thumb--active');
    const activeTop = document.querySelector('.thumb--active').offsetTop;
    const firstOffset = firstThumb.offsetLeft;
    // Set the scroll position to show the selected thumb with some space to the left (200px)
    thumbs.scrollLeft = activeThumb.offsetLeft - firstOffset - 200;
    // Set the scroll top to scroll to pressed thumbnail image for mobile devices
    lightbox.scrollTop = activeTop - 30;
  }

  /*
    renderOverlay function
    Parameters:
      - maxImages = based on the layout prop, how many images are the maximum that will show on page
      - i = the current image number
  */
 renderOverlay = (maxImages, i) => {
   const { images } = this.props;
   // Set overflow images to the amount of EXTRA images not showing on page
   const overflowImages = images.length - maxImages;
   // plural Or No is set to "s" if there is more than one and blank if there is just one
   const pluralOrNo = overflowImages > 1 ? 's' : '';
   // If there are more images than the max amount showing AND it is the last image
   if (images.length > maxImages && i === maxImages) {
     // Return an overlay with an extra class and content showing the amount of images left
     return (
       <div className="gallery-image__overlay gallery-image__overlay--last">
         {`+${overflowImages} more image${pluralOrNo}`}
       </div>
     );
   }
   // Otherwise...

   // Return the blank overlay
   return <div className="gallery-image__overlay" />;
 }

 /*
  galleryImage function
  Parameters:
    - cols = Chassis columns defined based on the selected style and which image it is
    - path = image.path
    - alt = image.alt
    - i = image number
 */
 galleryImage = (cols, path, alt, maxImages, i) => (
   <div className={cols}>
     <a
       onClick={e => this.onClick(e, i)}
       href="#lightbox"
     >
       <div className="gallery-image">
         <img
           src={path}
           alt={alt}
           className="ch-img--responsive ch-hand gallery-image__image"
         />
         {this.renderOverlay(maxImages, (i + 1))}
       </div>
     </a>
   </div>
 )

  // renderImages function
  renderImages = () => {
    let cols;
    let maxImages;
    const { layout, images } = this.props;
    if (layout === '4/3') {
      maxImages = 7;
    } else if (layout === '4') {
      maxImages = 4;
    } else if (layout === '6') {
      maxImages = 6;
    } else {
      maxImages = layout === '4/3' ? 7 : 8;
    }
    // Cleaned images array is the first 7 images
    const cleanedImages = images.slice(0, maxImages);
    // Amount is the length of that array (I've done this incase we change 7 to a different number)
    const amount = cleanedImages.length;
    // Map the images
    const returnImages = cleanedImages.map((image, i) => {
      // If the defined style is four by 3...
      if (layout === '4/3') {
        // Layout for the second and third-last image
        if ((amount - 1) === i + 1 || (amount - 2) === i + 1) cols = 'xs:ch-col--6 sm:ch-col--4 ch-mb--2 sm:ch-mb--4';
        // Layout for the last image
        else if (amount === i + 1) cols = 'xs:ch-col--12 sm:ch-col--4 ch-mb--2 sm:ch-mb--4';
        // Otherwise, layout is just a simple grid
        else cols = 'xs:ch-col--6 sm:ch-col--3 ch-mb--2 sm:ch-mb--4';
      } else if (layout === '6') {
        // If the defined style is four by 3...
        // Layout is just a simple grid
        cols = 'xs:ch-col--6 sm:ch-col--4 ch-mb--2 sm:ch-mb--4';
      } else cols = 'xs:ch-col--6 sm:ch-col--3 ch-mb--2 sm:ch-mb--4';
      // Return an image from the galleryImage function based on the parameters from above
      return (
        this.galleryImage(cols, image.path, image.alt, maxImages, i)
      );
    });
    // Return images
    return returnImages;
  }

  // renderLightbox function
  renderLightbox = () => {
    const showLightbox = this.state;
    // Listen for keydown event and call function
    document.addEventListener('keydown', this.handleKeyDown);
    // Render lightbox
    const lightbox = (
      <div
        className={`lightbox ${showLightbox ? 'lightbox--visible' : ''}`}
      >
        {this.renderImage()}
        {this.renderCounter()}
        <div className="thumbs ch-mh--auto">
          {this.renderThumbnails()}
        </div>
        <button
          className="ch-pull--right close-button ch-ma--3"
          onClick={e => this.onClose(e)}
          type="button"
        />
      </div>
    );
    return lightbox;
  }

  // renderImage function to show featuredImage
  renderImage = () => {
    const { images } = this.props;
    const { activeImage } = this.state;
    return (
      <div className="ch-display--none md:ch-display--flex imageContainer">
        <figure>
          <div className="overlays ch-mh--auto md:ch-mt--8 ch-hand">
            <div
              className="overlay"
              onClick={e => this.changeImage(e, -1)}
            />
            <div
              className="overlay"
              onClick={e => this.changeImage(e, 1)}
            />
          </div>
          <img
            src={images[activeImage].path}
            alt={images[activeImage].alt}
            className="ch-img--responsive featuredImage ch-mh--auto md:ch-mt--8 ch-hand"
            onClick={e => this.changeImage(e, 1)}
          />
          <figcaption className="caption ch-mt--1 ch-mh--auto ch-mb--4 ch-text--center">{images[activeImage].caption}</figcaption>
        </figure>
        {this.renderNavigation()}
      </div>
    );
  }

  // renderCounter function to show which image the user is on
  renderCounter = () => {
    const { images } = this.props;
    const { activeImage } = this.state;
    return (
      <p className="counter ch-display--none md:ch-display--block ch-text--center ch-mb--0">
        {`Image ${activeImage + 1}/${images.length}`}
      </p>
    );
  }

  // renderNavigation function to show arrows
  renderNavigation = () => (
    <div className="arrows ch-display--none md:ch-display--block">
      <button
        className="arrow arrows__left ch-absolute"
        onClick={e => this.changeImage(e, -1)}
        type="button"
      />
      <button
        className="arrow arrows__right ch-absolute"
        onClick={e => this.changeImage(e, 1)}
        type="button"
      />
    </div>
  )

  // renderThumbnails function to show list of thumbnails (On mobile these will be used)
  renderThumbnails = () => {
    const { images } = this.props;
    const { activeImage } = this.state;
    const thumbs = images.map((image, i) => (
      <div
        className={`thumb md:ch-display--inline-block ch-mt--4 md:ch-mt--2 ch-mr--2${i === activeImage ? ' thumb--active md:ch-ba--2 md:ch-bc--white' : ''}`}
        onClick={e => this.onClick(e, i)}
      >
        <figure>
          <img
            src={images[i].path}
            alt={images[i].alt}
            className="ch-img--responsive ch-mh--auto ch-mt--4 md:ch-mt--0"
          />
          <figcaption className="caption ch-mt--1 ch-mh--auto ch-mb--4 md:ch-mb--8 md:ch-display--none">{images[i].caption}</figcaption>
        </figure>
      </div>
    ));
    return thumbs;
  }

  // Final render function
  render() {
    const { showLightbox } = this.state;
    return (
      <div>
        {this.renderImages()}
        {showLightbox ? this.renderLightbox() : null}
      </div>
    );
  }
}

我认为你写错了。

应该是:

render() {
  return (
    <Gallery ...
  )
}

有同样的问题。将 .babelrc 重命名为 babel.config.js 对我有用。

示例 babel.config.js -

module.exports = {
  presets: ["@babel/preset-env", "@babel/preset-react"],
  plugins: ["@babel/plugin-proposal-class-properties", "@babel/plugin-syntax-dynamic-import"],
};

这里的问题在于 Babel 编译 Preact 的方式。我必须添加 @babel/plugin-transform-react-jsx 插件才能让我的 Jest 测试工作。

原来它在 Global pragma 部分的 Preact docs 中有模糊的记录。

解决方案

1。安装插件

npm i @babel/plugin-transform-react-jsx --save-dev

2。更新 .babelrc

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": {
          "node": "current"
        }
      },
      "@babel/preset-react"
    ]
  ],
  "plugins": [
    [
      "@babel/plugin-transform-runtime", {
        "regenerator": true
      }
    ],
    "@babel/plugin-proposal-class-properties",
    "@babel/plugin-proposal-export-default-from",
    ["@babel/plugin-transform-react-jsx", { "pragma":"h" }]
  ]
}