Как установить ширину компонента React во время теста?

Я пытаюсь проверить компонент слайдера.

Этот компонент слайдера может быть переменным по ширине. Когда вы нажимаете "дорожку" слайдера, он должен изменить значение и вызвать обратный вызов onChange. Значение зависит от того, где вы нажимаете на дорожку. Если вы нажмете половину точки, когда значение min 100, а максимальное значение 200, тогда оно должно сообщать значение 150.

Проблема, с которой я сталкиваюсь, заключается в том, что при рендеринге компонента с помощью ReactTest.renderIntoDocument у компонента нет ширины, поэтому при щелчке по нему он не может вычислить новое значение.

Вот компонент Slider.js

import React, {PropTypes} from 'react';
import ReactDOM from 'react-dom';
import { noop } from 'lodash';
import style from './style.scss';

export default class Slider extends React.Component {
  render() {
    return (
      <div
        className='Slider'
        onClick={this.handleClick.bind(this)}
        {...this.props}
      >
        <div
          className='handle'
          style={{left: `${this.calculateLeft()}%`}}>
        </div>
        <div className='track'></div>
      </div>
    );
  }

  handleClick(e) {
    let node = ReactDOM.findDOMNode(this);
    let {clientX, clientY} = e;
    let {offsetLeft, offsetWidth, clientWidth} = node;
    let xPercent = (clientX - offsetLeft) / offsetWidth;
    console.log(offsetLeft, offsetWidth, clientWidth, xPercent);
    this.props.onChange(normalize(xPercent, this.props.min, this.props.max));
  }

  calculateLeft() {
    let numerator = this.props.value - this.props.min;
    let denominator = this.props.max - this.props.min;
    return numerator / denominator * 100;
  }
}

// Proptypes
// ----------------------------------------------------------------------------
Slider.propTypes = {
  // Callback for when the value changes.
  onChange: PropTypes.func,
  // The value for when the slider is at 0%
  min: PropTypes.number,
  // The value for when the slider is at 100%
  max: PropTypes.number,
  // The starting value
  value: validateValue,
}

Slider.defaultProps = {
  onChange: noop,
  min: 0,
  max: 100,
}

// Custom Validation
// ----------------------------------------------------------------------------
function validateValue(props, propName, componentName) {
  let value = props[propName];

  if (typeof(value) !== 'number') {
    return new Error(`value must be a number, got ${typeof(value)}`);
  }

  if (value > props.max || value < props.min) {
    return new Error(
      `value: ${value} must be between max: ${props.max}
      and min: ${props.min}`
    );
  }
}

// Helpers
// ---------------------------------------------------------------------------

function normalize(floatValue, min, max) {
  let range = max - min;
  let normalizedValue = floatValue * range + min;
  // cleverly restrict the value be between the min and max
  return [min, normalizedValue, max].sort()[1];
}

Таблица стилей (style.scss):

.Slider {
  position: relative;
  display: block;
  width: 100px;

  .track {
    height: 4px;
    background: #666;
    border-radius: 2px;
  }

  .handle {
    width: 12px;
    height: 12px;
    background: #fff;
    border-radius: 10px;
    position: absolute;
    top: 50%;
    transform: translate(-50%, -50%);
    transition: left 100ms linear;
  }
}

Вот мой тест:

import Slider from './Slider';
import React from 'react';
import {
  renderIntoDocument,
  findRenderedDOMComponentWithClass,
  findRenderedDOMComponentWithTag,
  Simulate
} from 'react-addons-test-utils';

describe('Slider', function() {

  describe('click', function() {
    it('triggers the onChange callback', function() {
      const onChange = sinon.spy();
      const component = renderIntoDocument(
        <Slider
          style={{width: 100, height: 40}}
          min={100}
          max={200}
          value={150}
          onChange={onChange}
        />
      );

      const track = findRenderedDOMComponentWithClass(component, 'track');

      Simulate.click(track, {clientY: 0, clientX: 10})
      expect(onChange).to.have.been.calledWith(110);
    });
  });
});

Тестовый выход

LOG LOG: 0, 0, 0, Infinity
click
  ✗ triggers the onChange callback
AssertionError: expected onChange to have been called with arguments 10
    onChange(200)

    at /components/Slider/test.js:99 < webpack:///src/components/Slider/test.js:55:6

Эти операторы журнала относятся к функции handleClick() в компоненте.

Ширина равна нулю, поэтому знаменатель заканчивается равным нулю при вычислении xPercent, что приводит к его бесконечности. Это заставляет просто использовать значение max 200.

TL;DR

Как заставить компонент иметь ширину при рендеринге во время теста?

Ответ 1

Сегодня я сам борюсь с той же проблемой - я создаю компонент, который будет масштабировать его размер текста в зависимости от размера элемента. Поскольку renderIntoDocument помещает ваш компонент в отдельный DOM node, невозможно вычислить offsetWidth, clientWidth и т.д.

Вы тестируете в браузере или node.js? (EDIT: Я вижу, что вы отметили вопрос PhantomJS, поэтому я предполагаю браузер!) Если вы находитесь в браузере, вы можете сделать компонент в DOM реальным:

React.render(<Slider />, document.body);

Если вы беспокоитесь об изоляции тестов, вы можете создать IFrame для визуализации компонента и затем очистить его:

beforeEach(function() {
    this.iframe = document.createElement('iframe');
    document.body.appendChild(this.iframe);
});

React.render(<Slider />, this.iframe.contentDocument.body);

afterEach(function() {
    document.body.removeChild(this.iframe);
});

Затем вызовите this.iframe.contentDocument.body.querySelectorAll('.track'), чтобы получить элемент HTML и выполнить свои утверждения против него (это простой элемент HTML, а не компонент React, поэтому используйте стандартные API для запроса).

Ответ 2

Вот пример. React 0.14 предупреждает об рендеринге документа в тело. Как и то, что сказал Мэтт, нам нужно добавить "div" в iframe, чтобы предотвратить такие ошибки.

describe('html tooltip utility class', function() {

let iframe;
let div;

beforeEach(() => {
    iframe = document.createElement('iframe');
    document.body.appendChild(iframe);
    div = document.createElement('div');
});


it('returns true if text overflows', () => {
    // jshint ignore:start
    let style = {
        width: 5
    };
    let data = 'hello this is a long text.';
    iframe.contentDocument.body.appendChild(div);
    ReactDOM.render(<div style={style}>{data}</div>, div);

    // jshint ignore:end
    let textNode = div.querySelectorAll('div')[0];

    expect(HTMLTooltip.showTooltip(textNode)).to.be.true;
});

afterEach(() => {
    document.body.removeChild(iframe);
});
});

Ответ 3

Вы пытались сначала установить размер фактического dom node перед его тестированием? Я использую Enzyme, и то, что я обычно делаю, это создать фиктивный элемент node, прикрепленный к телу, а затем подключить компонент к этому элементу. Если мне нужно настроить ширину и высоту элемента внутри компонента, я просто обновляю его реальную DOM node ширину и высоту с помощью javascript. Я отправляю свой пример кода ниже, надеюсь, что это поможет.

Компонентный код, который необходимо протестировать

getMaskComponentContent() {
    const { clientWidth, clientHeight } = this.button;
    const size = clientWidth + clientHeight;
    const lineGap = 15;
    let lines = [];

    for (let i = lineGap; i < size; i += lineGap) {
        lines.push(<line key={i} className='lrn-line1' x1='0' y1={i} x2={i} y2='0'/>);
    }

    return (
        <svg className='lrn-mask' xmlns='http://www.w3.org/2000/svg'>
            <rect x='0' y='0' width='100%' height='100%' fill='transparent'/>
            {lines}
        </svg>
    );
}

Единичный тест с ферментом

let wrapper, mountElement;

function setup(props = {}, mountOptions) {
    const providerWrapper = enzyme.mount(<MaskableElement {...props}/>, mountOptions);

    wrapper = providerWrapper.find('MaskableElement');
}

beforeEach(function () {
    // Create dummy element
    mountElement = document.createElement('DIV');
    document.body.appendChild(mountElement);
});

afterEach(function () {
    mountElement.remove();
});

it('the masking svg should contain multiple line elements based on the width and height of the main button', function () {
    // First we setup the component without maskId
    setup({
        maskIds: []
    }, {
        attachTo: mountElement
    });

    const button = wrapper.find('button');
    const node = button.node;

    // then we set size to the component
    node.style.width = '300px';
    node.style.height = '30px';

    // stimulate click event to apply the mask
    button.simulate('click');

    const svg = button.find('svg');

    // 330 is the total clientWidth + clientHeight, 15 is the gap b/w lines
    const expectedNumberOfLines = (330 / 15) - 1; 

    expect(svg.find('line').length).toEqual(expectedNumberOfLines);
});